This commit is contained in:
Joe
2026-01-16 15:49:34 +08:00
commit 550d3e1f42
380 changed files with 62024 additions and 0 deletions
@@ -0,0 +1,730 @@
package admin
import (
"fmt"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/carbon"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/services"
)
type AdminController struct {
adminService services.AdminService
googleAuthenticatorService services.GoogleAuthenticatorService
}
// AdminExportRequest 导出管理员请求参数
type AdminExportRequest struct {
Username string `json:"username" form:"username" example:"admin"` // 用户名(模糊搜索)
Status string `json:"status" form:"status" example:"1"` // 状态:1-启用,0-禁用
RoleID string `json:"role_id" form:"role_id" example:"1"` // 角色ID
DepartmentID string `json:"department_id" form:"department_id" example:"1"` // 部门ID
Is2FABound string `json:"is_2fa_bound" form:"is_2fa_bound" example:"1"` // 是否绑定2FA:1-已绑定,0-未绑定
StartTime string `json:"start_time" form:"start_time" example:"2024-01-01 00:00:00"` // 开始时间
EndTime string `json:"end_time" form:"end_time" example:"2024-12-31 23:59:59"` // 结束时间
OrderBy string `json:"order_by" form:"order_by" example:"created_at:desc"` // 排序
}
// AdminResponse 管理员响应数据
type AdminResponse struct {
ID uint `json:"id" example:"1"` // 管理员ID
Username string `json:"username" example:"admin"` // 用户名
Nickname string `json:"nickname" example:"管理员"` // 昵称
Avatar string `json:"avatar" example:""` // 头像
Email string `json:"email" example:"admin@example.com"` // 邮箱
Phone string `json:"phone" example:"13800138000"` // 手机号
Status uint8 `json:"status" example:"1"` // 状态:1-启用,0-禁用
Is2FABound bool `json:"is_2fa_bound" example:"true"` // 是否绑定2FA
DepartmentID uint `json:"department_id" example:"1"` // 部门ID
Department map[string]any `json:"department"` // 部门信息
Roles []map[string]any `json:"roles"` // 角色列表
CreatedAt string `json:"created_at" example:"2024-01-01 00:00:00"` // 创建时间
UpdatedAt string `json:"updated_at" example:"2024-01-01 00:00:00"` // 更新时间
}
// PaginatedAdminResponse 分页管理员响应
type PaginatedAdminResponse struct {
Code int `json:"code" example:"200"` // 状态码
Message string `json:"message" example:"获取成功"` // 消息
Data []AdminResponse `json:"data"` // 数据列表
Total int64 `json:"total" example:"100"` // 总数
Page int `json:"page" example:"1"` // 当前页码
PageSize int `json:"page_size" example:"10"` // 每页数量
TraceID string `json:"trace_id,omitempty" example:"abc123"` // 追踪ID
}
// AdminDetailResponse 管理员详情响应
type AdminDetailResponse struct {
Code int `json:"code" example:"200"` // 状态码
Message string `json:"message" example:"获取成功"` // 消息
Data AdminResponse `json:"data"` // 管理员数据
TraceID string `json:"trace_id,omitempty" example:"abc123"` // 追踪ID
}
func NewAdminController() *AdminController {
return &AdminController{
adminService: services.NewAdminServiceImpl(),
googleAuthenticatorService: services.NewGoogleAuthenticatorServiceImpl(),
}
}
// findAdminByID 根据ID查找管理员,如果不存在则返回错误响应
// withDepartment 为 true 时会预加载 Department 关联
// withRoles 为 true 时会预加载 Roles 关联
func (r *AdminController) findAdminByID(ctx http.Context, id uint, withDepartment bool, withRoles bool) (*models.Admin, http.Response) {
admin, err := r.adminService.GetByID(id, withDepartment, withRoles)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
return admin, nil
}
// buildFilters 构建查询过滤器(列表和导出共用)
// 同时支持查询参数(GET)和请求体参数(POST)
func (r *AdminController) buildFilters(ctx http.Context) services.AdminFilters {
// 优先从请求体读取,如果没有则从查询参数读取(兼容 GET 和 POST)
username := ctx.Request().Input("username", ctx.Request().Query("username", ""))
status := ctx.Request().Input("status", ctx.Request().Query("status", ""))
roleID := ctx.Request().Input("role_id", ctx.Request().Query("role_id", ""))
departmentID := ctx.Request().Input("department_id", ctx.Request().Query("department_id", ""))
is2FABound := ctx.Request().Input("is_2fa_bound", ctx.Request().Query("is_2fa_bound", ""))
orderBy := ctx.Request().Input("order_by", ctx.Request().Query("order_by", ""))
// 时间参数同时支持从请求体和查询参数读取,并转换为 UTC
startTimeStr := ctx.Request().Input("start_time", ctx.Request().Query("start_time", ""))
endTimeStr := ctx.Request().Input("end_time", ctx.Request().Query("end_time", ""))
startTime := ""
endTime := ""
if startTimeStr != "" {
startTime = helpers.ConvertTimeToUTC(ctx, startTimeStr)
}
if endTimeStr != "" {
endTime = helpers.ConvertTimeToUTC(ctx, endTimeStr)
}
return services.AdminFilters{
Username: username,
Status: status,
RoleID: roleID,
DepartmentID: departmentID,
Is2FABound: is2FABound,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 管理员列表
// @Summary 获取管理员列表
// @Description 分页获取管理员列表,支持按用户名、状态、角色、部门等条件筛选
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param username query string false "用户名(模糊搜索)"
// @Param status query string false "状态:1-启用,0-禁用"
// @Param role_id query string false "角色ID"
// @Param department_id query string false "部门ID"
// @Param start_time query string false "开始时间(格式:YYYY-MM-DD HH:mm:ss"
// @Param end_time query string false "结束时间(格式:YYYY-MM-DD HH:mm:ss"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} PaginatedAdminResponse
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins [get]
// @Security BearerAuth
func (r *AdminController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
admins, total, err := r.adminService.GetList(filters, page, pageSize)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
// 获取超级管理员ID
superAdminID := cast.ToUint(facades.Config().GetInt("admin.super_admin_id", 1))
// 转换数据格式
adminList := make([]http.Json, len(admins))
for i, admin := range admins {
isBound := admin.GoogleSecret != ""
adminList[i] = http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"email": admin.Email,
"phone": admin.Phone,
"status": admin.Status,
"is_2fa_bound": isBound,
"is_super_admin": admin.ID == superAdminID,
"department_id": admin.DepartmentID,
"department": admin.Department,
"roles": admin.Roles,
"created_at": admin.CreatedAt,
"updated_at": admin.UpdatedAt,
}
}
return response.Success(ctx, http.Json{
"list": adminList,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 管理员详情
// @Summary 获取管理员详情
// @Description 根据ID获取管理员详细信息,包括部门、角色等关联信息
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param id path int true "管理员ID"
// @Success 200 {object} AdminDetailResponse
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 404 {object} map[string]any "管理员不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/{id} [get]
// @Security BearerAuth
func (r *AdminController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
admin, resp := r.findAdminByID(ctx, id, true, true) // 预加载 Department 和 Roles 关联
if resp != nil {
return resp
}
// 获取超级管理员ID
superAdminID := cast.ToUint(facades.Config().GetInt("admin.super_admin_id", 1))
return response.Success(ctx, http.Json{
"admin": http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"email": admin.Email,
"phone": admin.Phone,
"status": admin.Status,
"is_super_admin": admin.ID == superAdminID, // 标识是否是超级管理员
"department_id": admin.DepartmentID,
"department": admin.Department,
"roles": admin.Roles,
"created_at": admin.CreatedAt,
"updated_at": admin.UpdatedAt,
},
})
}
// Store 创建管理员
// @Summary 创建管理员
// @Description 创建新的管理员账号,支持设置部门、角色等信息
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param username body string true "用户名(必填)" example(admin)
// @Param password body string true "密码(必填)" example(123456)
// @Param nickname body string false "昵称" example(管理员)
// @Param email body string false "邮箱" example(admin@example.com)
// @Param phone body string false "手机号" example(13800138000)
// @Param department_id body int false "部门ID" example(1)
// @Param status body int false "状态:1-启用,0-禁用" example(1)
// @Param role_ids body []int false "角色ID列表" example([1,2])
// @Success 200 {object} AdminDetailResponse
// @Failure 400 {object} map[string]any "参数错误或用户名已存在"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins [post]
// @Security BearerAuth
func (r *AdminController) Store(ctx http.Context) http.Response {
// 使用请求验证
var adminCreate adminrequests.AdminCreate
errors, err := ctx.Request().ValidateRequest(&adminCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 检查用户名是否已存在
exists, err := facades.Orm().Query().Model(&models.Admin{}).Where("username", adminCreate.Username).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrUsernameExists.Code)
}
// 加密密码
hashedPassword, err := facades.Hash().Make(adminCreate.Password)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrPasswordEncryptFailed.Code)
}
now := carbon.Now()
adminData := map[string]any{
"username": adminCreate.Username,
"password": hashedPassword,
"nickname": adminCreate.Nickname,
"avatar": "",
"email": adminCreate.Email,
"phone": adminCreate.Phone,
"department_id": adminCreate.DepartmentID,
"status": adminCreate.Status,
"created_at": now,
"updated_at": now,
}
if err := facades.Orm().Query().Table("admins").Create(adminData); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"username": adminCreate.Username,
})
}
var admin models.Admin
if err := facades.Orm().Query().Where("username", adminCreate.Username).FirstOrFail(&admin); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"username": adminCreate.Username,
})
}
if len(adminCreate.RoleIDs) > 0 {
if err := r.adminService.SyncRoles(&admin, adminCreate.RoleIDs); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": admin.ID,
"role_ids": adminCreate.RoleIDs,
})
}
}
return response.Success(ctx, http.Json{
"admin": admin,
})
}
// Update 更新管理员
// @Summary 更新管理员信息
// @Description 更新管理员的基本信息,包括昵称、邮箱、手机号、部门、状态、角色等。受保护的管理员不能禁用。
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param id path int true "管理员ID" example(1)
// @Param nickname body string false "昵称" example(管理员)
// @Param email body string false "邮箱" example(admin@example.com)
// @Param phone body string false "手机号" example(13800138000)
// @Param department_id body int false "部门ID" example(1)
// @Param status body string false "状态:1-启用,0-禁用" example(1)
// @Param password body string false "密码(可选,不传则不更新)" example(123456)
// @Param role_ids body []int false "角色ID列表" example([1,2])
// @Success 200 {object} AdminDetailResponse
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限或受保护管理员不能禁用"
// @Failure 404 {object} map[string]any "管理员不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/{id} [put]
// @Security BearerAuth
func (r *AdminController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 加载管理员的当前角色,用于后续比较角色是否改变
admin, resp := r.findAdminByID(ctx, id, false, true) // 预加载 Roles 关联
if resp != nil {
return resp
}
allProtectedIDs := r.getAllProtectedAdminIDs()
isProtected := allProtectedIDs[id]
// 使用请求验证
var adminUpdate adminrequests.AdminUpdate
errors, err := ctx.Request().ValidateRequest(&adminUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["nickname"]; exists {
admin.Nickname = adminUpdate.Nickname
}
if _, exists := allInputs["email"]; exists {
admin.Email = adminUpdate.Email
}
if _, exists := allInputs["phone"]; exists {
admin.Phone = adminUpdate.Phone
}
if _, exists := allInputs["department_id"]; exists {
admin.DepartmentID = adminUpdate.DepartmentID
}
if _, exists := allInputs["status"]; exists {
// 请求中提供了 status 字段,使用验证后的值
// 检查是否是超级管理员或受保护的管理员
superAdminID := cast.ToUint(facades.Config().GetInt("admin.super_admin_id", 1))
isSuperAdmin := admin.ID == superAdminID
if (isProtected || isSuperAdmin) && adminUpdate.Status == 0 {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAdminProtectedCannotDisable.Code)
}
admin.Status = adminUpdate.Status
}
if adminUpdate.Password != "" {
hashedPassword, err := facades.Hash().Make(adminUpdate.Password)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrPasswordEncryptFailed.Code)
}
admin.Password = hashedPassword
}
if err := r.adminService.Update(admin); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": admin.ID,
})
}
// 检查是否尝试修改 admin 用户的角色
if _, exists := allInputs["role_ids"]; exists {
// 获取当前管理员的角色ID列表(去重)
currentRoleIDSet := make(map[uint]bool)
var currentRoleIDs []uint
for _, role := range admin.Roles {
if !currentRoleIDSet[role.ID] {
currentRoleIDSet[role.ID] = true
currentRoleIDs = append(currentRoleIDs, role.ID)
}
}
// 对传入的角色ID进行去重
newRoleIDSet := make(map[uint]bool)
var deduplicatedRoleIDs []uint
for _, roleID := range adminUpdate.RoleIDs {
if !newRoleIDSet[roleID] {
newRoleIDSet[roleID] = true
deduplicatedRoleIDs = append(deduplicatedRoleIDs, roleID)
}
}
// 比较新的角色ID列表和当前的角色ID列表
// 只有当角色ID真正改变时才阻止修改
roleIDsChanged := false
// 如果长度不同,肯定改变了
if len(deduplicatedRoleIDs) != len(currentRoleIDs) {
roleIDsChanged = true
} else {
// 长度相同,需要检查内容是否完全一致(忽略顺序)
// 检查新的角色ID是否都在当前角色ID中
for _, newRoleID := range deduplicatedRoleIDs {
if !currentRoleIDSet[newRoleID] {
roleIDsChanged = true
break
}
}
// 如果所有新角色ID都在当前角色ID中,且长度相同,说明没有改变
}
// 检查是否是超级管理员(通过配置的ID判断,不依赖用户名)
superAdminID := cast.ToUint(facades.Config().GetInt("admin.super_admin_id", 1))
isSuperAdmin := admin.ID == superAdminID
// 只有当角色ID真正改变时才阻止修改
// 如果角色ID没有改变,允许调用 SyncRoles 来清理重复数据
if roleIDsChanged && isSuperAdmin {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAdminCannotModifyRoles.Code)
}
// 即使角色ID没有改变,也调用 SyncRoles 来清理重复数据
// 使用去重后的角色ID列表
if err := r.adminService.SyncRoles(admin, deduplicatedRoleIDs); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": admin.ID,
"role_ids": deduplicatedRoleIDs,
})
}
}
return response.Success(ctx, http.Json{
"admin": *admin,
})
}
// Destroy 删除管理员
// @Summary 删除管理员
// @Description 删除指定的管理员账号。受保护的管理员和当前登录的管理员不能删除。
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param id path int true "管理员ID"
// @Success 200 {object} map[string]any "删除成功"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限、受保护管理员不能删除或不能删除自己"
// @Failure 404 {object} map[string]any "管理员不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/{id} [delete]
// @Security BearerAuth
func (r *AdminController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
allProtectedIDs := r.getAllProtectedAdminIDs()
if allProtectedIDs[id] {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAdminProtectedCannotDelete.Code)
}
adminValue := ctx.Value("admin")
if adminValue != nil {
var currentAdmin models.Admin
if admin, ok := adminValue.(models.Admin); ok {
currentAdmin = admin
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
currentAdmin = *adminPtr
}
if currentAdmin.ID > 0 && currentAdmin.ID == id {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAdminCannotDeleteSelf.Code)
}
}
admin, resp := r.findAdminByID(ctx, id, false, false)
if resp != nil {
return resp
}
if _, err := facades.Orm().Query().Delete(admin); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx)
}
// UnbindGoogleAuthenticator 管理员解绑其他管理员的谷歌验证码
// @Summary 解绑管理员的谷歌验证码
// @Description 管理员可以解绑其他管理员的谷歌验证码,需要当前管理员已绑定谷歌验证码并输入验证码确认
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param id path int true "要解绑的管理员ID"
// @Param code body string true "当前管理员的谷歌验证码"
// @Success 200 {object} map[string]any "解绑成功"
// @Failure 400 {object} map[string]any "参数错误或验证码错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限或当前管理员未绑定谷歌验证码"
// @Failure 404 {object} map[string]any "管理员不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/{id}/unbind-google-auth [post]
// @Security BearerAuth
func (r *AdminController) UnbindGoogleAuthenticator(ctx http.Context) http.Response {
// 获取要解绑的管理员ID
targetAdminID := helpers.GetUintRoute(ctx, "id")
if targetAdminID == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
// 检查目标管理员是否存在
var targetAdmin models.Admin
if err := facades.Orm().Query().Where("id", targetAdminID).FirstOrFail(&targetAdmin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
// 从context中获取当前管理员信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var currentAdmin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
currentAdmin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
currentAdmin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 检查当前管理员是否已绑定谷歌验证码
isBound, err := r.googleAuthenticatorService.IsBound(currentAdmin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": currentAdmin.ID,
})
}
if !isBound {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrGoogleAuthenticatorNotBound.Code)
}
// 需要验证码确认
code := ctx.Request().Input("code")
if code == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrCodeRequired.Code)
}
// 获取当前管理员的密钥
secret, err := r.googleAuthenticatorService.GetSecret(currentAdmin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": currentAdmin.ID,
})
}
if secret == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleAuthenticatorNotBound.Code)
}
// 验证当前管理员的验证码
if !r.googleAuthenticatorService.Verify(secret, code) {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeInvalid.Code)
}
// 检查目标管理员是否已绑定
targetIsBound, err := r.googleAuthenticatorService.IsBound(targetAdminID)
if err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"target_admin_id": targetAdminID,
})
}
if !targetIsBound {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleAuthenticatorNotBound.Code)
}
// 解绑目标管理员的谷歌验证码
if err := r.googleAuthenticatorService.Unbind(targetAdminID); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"target_admin_id": targetAdminID,
"current_admin_id": currentAdmin.ID,
})
}
return response.Success(ctx, "unbind_success")
}
// getAllProtectedAdminIDs 获取所有受保护的管理员ID(用于删除等操作)
func (r *AdminController) getAllProtectedAdminIDs() map[uint]bool {
return r.adminService.GetProtectedAdminIDs()
}
// Export 导出管理员列表
// @Summary 导出管理员列表
// @Description 根据筛选条件导出管理员列表为CSV文件,支持与列表查询相同的筛选条件
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param request body AdminExportRequest false "导出筛选条件(可选)"
// @Success 200 {object} map[string]any "导出成功,返回文件下载信息"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/export [post]
// @Security BearerAuth
func (r *AdminController) Export(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 防重复点击:使用框架自带的原子锁(锁会在10秒后自动过期,防止短时间内重复请求)
lockKey := fmt.Sprintf("export:admins:lock:%d", adminID)
lock := facades.Cache().Lock(lockKey, 10*time.Second)
// 尝试获取锁,如果获取失败则返回错误
if !lock.Get() {
return response.Error(ctx, http.StatusTooManyRequests, "export_in_progress")
}
// 同步导出:锁会在 Redis 中自动过期(10秒),不需要手动释放
filters := r.buildFilters(ctx)
// 导出时获取所有数据,不分页
admins, err := r.adminService.GetAllAdminsForExport(filters)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
headers := []string{
"export_header_id",
"export_header_username",
"export_header_nickname",
"export_header_email",
"export_header_phone",
"export_header_status",
"export_header_department",
"export_header_roles",
"export_header_created_at",
"export_header_updated_at",
}
timezone := helpers.GetCurrentTimezone(ctx)
var data [][]string
for _, admin := range admins {
statusText := trans.Get(ctx, "export_status_disabled")
if admin.Status == 1 {
statusText = trans.Get(ctx, "export_status_enabled")
}
// 部门名称
departmentName := ""
if admin.Department.ID > 0 {
departmentName = admin.Department.Name
}
// 角色名称(多个角色用逗号分隔)
roleNames := ""
if len(admin.Roles) > 0 {
for i, role := range admin.Roles {
if i > 0 {
roleNames += ", "
}
roleNames += role.Name
}
}
// 时间格式化
createdAt := helpers.FormatCarbonWithTimezone(admin.CreatedAt, timezone)
updatedAt := helpers.FormatCarbonWithTimezone(admin.UpdatedAt, timezone)
row := []string{
cast.ToString(admin.ID),
admin.Username,
admin.Nickname,
admin.Email,
admin.Phone,
statusText,
departmentName,
roleNames,
createdAt,
updatedAt,
}
data = append(data, row)
}
// 在 context 中设置导出类型,供 ExportService 使用
ctx.WithValue("export_type", models.ExportTypeAdmins)
return response.Export(ctx, "export_success", headers, data, "admins")
}
@@ -0,0 +1,136 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/services"
)
type ArticleController struct {
ArticleService services.ArticleService
}
func NewArticleController() *ArticleController {
return &ArticleController{
ArticleService: services.NewArticleService(),
}
}
// Index Article列表
func (c *ArticleController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
name := ctx.Request().Query("name", "")
status := ctx.Request().Query("status", "")
filters := services.ArticleFilters{
Name: name,
Status: status,
}
list, total, err := c.ArticleService.GetList(filters, page, pageSize)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": list,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show Article详情
func (c *ArticleController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
item, err := c.ArticleService.GetByID(id)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusNotFound, businessErr.Code)
}
return response.Error(ctx, http.StatusNotFound, err.Error())
}
return response.Success(ctx, http.Json{
"article": item,
})
}
// Store 创建Article
func (c *ArticleController) Store(ctx http.Context) http.Response {
var req adminrequests.ArticleCreate
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
item, err := c.ArticleService.Create(&req)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"article": item,
})
}
// Update 更新Article
func (c *ArticleController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
var req adminrequests.ArticleUpdate
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
item, err := c.ArticleService.Update(id, &req)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"article": item,
})
}
// Destroy 删除Article
func (c *ArticleController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if err := c.ArticleService.Delete(id); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, "delete_success", http.Json{})
}
@@ -0,0 +1,600 @@
package admin
import (
"fmt"
"mime"
"path/filepath"
"strconv"
"time"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/errorlog"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
type AttachmentController struct {
attachmentService services.AttachmentService
}
func NewAttachmentController() *AttachmentController {
return &AttachmentController{}
}
// Index 附件列表
func (r *AttachmentController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
attachmentService := services.NewAttachmentService(ctx)
attachments, total, err := attachmentService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err)
}
type AttachmentWithURL struct {
models.Attachment
FileURL string `json:"file_url"`
}
var resultWithURL []AttachmentWithURL
for _, a := range attachments {
fileURL := attachmentService.GetFileURL(&a)
resultWithURL = append(resultWithURL, AttachmentWithURL{
Attachment: a,
FileURL: fileURL,
})
}
return response.Success(ctx, http.Json{
"list": resultWithURL,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// buildFilters 构建查询过滤器
func (r *AttachmentController) buildFilters(ctx http.Context) services.AttachmentFilters {
adminID := ctx.Request().Query("admin_id", "")
filename := ctx.Request().Query("filename", "")
displayName := ctx.Request().Query("display_name", "")
fileType := ctx.Request().Query("file_type", "")
extension := ctx.Request().Query("extension", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.AttachmentFilters{
AdminID: adminID,
Filename: filename,
DisplayName: displayName,
FileType: fileType,
Extension: extension,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Upload 普通文件上传(小文件)
func (r *AttachmentController) Upload(ctx http.Context) http.Response {
file, err := ctx.Request().File("file")
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFileRequired.Code)
}
filename := file.GetClientOriginalName()
if filename == "" {
filename = "uploaded_file"
}
// 读取文件内容:先将文件保存到临时位置,然后读取
storage := facades.Storage().Disk("local")
// 保存文件到临时位置,PutFile 返回保存后的路径
savedPath, err := storage.PutFile("", file)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
})
}
// 读取文件内容
fileDataStr, err := storage.Get(savedPath)
if err != nil {
// 清理临时文件
_ = storage.Delete(savedPath)
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
})
}
// 清理临时文件
_ = storage.Delete(savedPath)
// 转换为字节数组
fileData := []byte(fileDataStr)
// 获取MIME类型:直接根据文件扩展名推断(multipart/form-data 的 Content-Type 不是文件本身的 MIME 类型)
ext := filepath.Ext(filename)
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.UploadFile(fileData, filename, mimeType)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
})
}
fileURL := attachmentService.GetFileURL(attachment)
return response.Success(ctx, "upload_success", http.Json{
"id": attachment.ID,
"filename": attachment.Filename,
"size": attachment.Size,
"mime_type": attachment.MimeType,
"file_type": attachment.FileType,
"file_url": fileURL,
})
}
// ChunkUpload 大文件分片上传统一接口
// 通过 action 参数区分不同操作:init(初始化)、upload(上传分片)、merge(合并分片)、progress(获取进度)
func (r *AttachmentController) ChunkUpload(ctx http.Context) http.Response {
action := ctx.Request().Input("action", "")
if action == "" {
// 兼容 GET 请求获取进度
action = ctx.Request().Query("action", "progress")
}
attachmentService := services.NewAttachmentService(ctx)
switch action {
case "init":
// 初始化分片上传
filename := ctx.Request().Input("filename", "")
if filename == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilenameRequired.Code)
}
totalSizeStr := ctx.Request().Input("total_size", "0")
totalSize, err := strconv.ParseInt(totalSizeStr, 10, 64)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalSize.Code)
}
if totalSize <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalSize.Code)
}
chunkSizeStr := ctx.Request().Input("chunk_size", "0")
chunkSize, err := strconv.ParseInt(chunkSizeStr, 10, 64)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidChunkSize.Code)
}
if chunkSize <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidChunkSize.Code)
}
totalChunksStr := ctx.Request().Input("total_chunks", "0")
totalChunks, err := strconv.Atoi(totalChunksStr)
if err != nil {
// 尝试作为浮点数解析(以防传入 "5.0" 格式)
if floatVal, floatErr := strconv.ParseFloat(totalChunksStr, 64); floatErr == nil {
totalChunks = int(floatVal)
} else {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
}
if totalChunks <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
// 验证分片数量计算的合理性
expectedChunks := int((totalSize + chunkSize - 1) / chunkSize) // 向上取整
if totalChunks != expectedChunks {
// 不返回错误,使用客户端提供的值(可能是由于浮点数计算差异)
}
chunkID, err := attachmentService.InitChunkUpload(filename, totalSize, chunkSize, totalChunks)
if err != nil {
// 使用业务错误类型,直接提取错误码
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
// 返回详细的错误信息
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
"total_size": totalSize,
"chunk_size": chunkSize,
"total_chunks": totalChunks,
})
}
return response.Success(ctx, "init_chunk_upload_success", http.Json{
"chunk_id": chunkID,
})
case "upload":
// 上传分片
chunkID := ctx.Request().Input("chunk_id", "")
if chunkID == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkIDRequired.Code)
}
chunkIndex, err := strconv.Atoi(ctx.Request().Input("chunk_index", "-1"))
if err != nil || chunkIndex < 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidChunkIndex.Code)
}
file, err := ctx.Request().File("chunk")
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkFileRequired.Code)
}
// 读取分片数据:先将文件保存到临时位置,然后读取
storage := facades.Storage().Disk("local")
// 保存文件到临时位置
savedPath, err := storage.PutFile("", file)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
})
}
// 读取文件内容
chunkDataStr, err := storage.Get(savedPath)
if err != nil {
// 清理临时文件
_ = storage.Delete(savedPath)
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
})
}
// 清理临时文件
_ = storage.Delete(savedPath)
// 转换为字节数组
chunkData := []byte(chunkDataStr)
if err := attachmentService.UploadChunk(chunkID, chunkIndex, chunkData); err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
})
}
return response.Success(ctx, "upload_chunk_success")
case "merge":
// 合并分片
chunkID := ctx.Request().Input("chunk_id", "")
if chunkID == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkIDRequired.Code)
}
filename := ctx.Request().Input("filename", "")
if filename == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilenameRequired.Code)
}
totalChunksStr := ctx.Request().Input("total_chunks", "0")
totalChunks, err := strconv.Atoi(totalChunksStr)
if err != nil {
// 尝试作为浮点数解析(以防传入 "5.0" 格式)
if floatVal, floatErr := strconv.ParseFloat(totalChunksStr, 64); floatErr == nil {
totalChunks = int(floatVal)
} else {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
}
if totalChunks <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
// 获取MIME类型:直接根据文件扩展名推断(前端传递的 mime_type 可能不准确)
ext := filepath.Ext(filename)
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
attachment, err := attachmentService.MergeChunks(chunkID, filename, mimeType, totalChunks)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"filename": filename,
"total_chunks": totalChunks,
})
}
// 合并成功后,记录日志(仅 Debug 模式)
facades.Log().Debugf("Successfully merged chunks for chunkID %s, filename: %s, total_chunks: %d", chunkID, filename, totalChunks)
fileURL := attachmentService.GetFileURL(attachment)
return response.Success(ctx, "merge_chunks_success", http.Json{
"id": attachment.ID,
"filename": attachment.Filename,
"size": attachment.Size,
"mime_type": attachment.MimeType,
"file_type": attachment.FileType,
"file_url": fileURL,
})
case "progress":
// 获取分片上传进度
chunkID := ctx.Request().Query("chunk_id", "")
if chunkID == "" {
chunkID = ctx.Request().Input("chunk_id", "")
}
if chunkID == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkIDRequired.Code)
}
totalChunks, err := strconv.Atoi(ctx.Request().Query("total_chunks", "0"))
if totalChunks == 0 {
totalChunks, err = strconv.Atoi(ctx.Request().Input("total_chunks", "0"))
}
if err != nil || totalChunks <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
progress, err := attachmentService.GetChunkProgress(chunkID, totalChunks)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
})
}
return response.Success(ctx, progress)
default:
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidAction.Code)
}
}
// Download 下载附件文件
func (r *AttachmentController) Download(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if attachment.Path == "" || attachment.Disk == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilePathRequired.Code)
}
// 对于云存储,尝试生成临时URL并重定向,避免通过服务器中转
if attachment.Disk != "local" && attachment.Disk != "public" {
storage := facades.Storage().Disk(attachment.Disk)
// 尝试生成临时URL(24小时有效)
if url, err := storage.TemporaryUrl(attachment.Path, time.Now().Add(24*time.Hour)); err == nil {
return ctx.Response().Redirect(http.StatusFound, url)
}
// 如果生成临时URL失败,尝试从配置获取基础URL
attachmentService := services.NewAttachmentService(ctx)
directURL := attachmentService.GetFileURL(attachment)
if directURL != "" && directURL != fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID) {
return ctx.Response().Redirect(http.StatusFound, directURL)
}
// 如果都失败,继续使用服务器中转方式
}
// 对于本地存储或临时URL生成失败的情况,使用服务器中转
storage := facades.Storage().Disk(attachment.Disk)
// 读取文件内容
content, err := storage.Get(attachment.Path)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"disk": attachment.Disk,
"path": attachment.Path,
})
}
// 设置响应头
filename := attachment.Filename
if filename == "" {
filename = attachment.Path
}
// 根据MIME类型设置 Content-Type
contentType := attachment.MimeType
if contentType == "" {
contentType = "application/octet-stream"
}
// 设置响应头,使用链式调用确保顺序正确
response := ctx.Response().
Header("Content-Type", contentType).
Header("Content-Length", fmt.Sprintf("%d", len(content))).
Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)).
Header("Cache-Control", "no-cache, no-store, must-revalidate").
Header("Pragma", "no-cache").
Header("Expires", "0")
return response.String(http.StatusOK, content)
}
// Preview 预览文件(图片、视频、文档)
func (r *AttachmentController) Preview(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if attachment.Path == "" || attachment.Disk == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilePathRequired.Code)
}
// 对于云存储,尝试生成临时URL并重定向,避免通过服务器中转
// 这样可以减少服务器带宽和内存占用,提高性能
if attachment.Disk != "local" && attachment.Disk != "public" {
storage := facades.Storage().Disk(attachment.Disk)
// 尝试生成临时URL(24小时有效)
if url, err := storage.TemporaryUrl(attachment.Path, time.Now().Add(24*time.Hour)); err == nil {
return ctx.Response().Redirect(http.StatusFound, url)
}
// 如果生成临时URL失败,尝试从配置获取基础URL
attachmentService := services.NewAttachmentService(ctx)
directURL := attachmentService.GetFileURL(attachment)
if directURL != "" && directURL != fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID) {
return ctx.Response().Redirect(http.StatusFound, directURL)
}
// 如果都失败,继续使用服务器中转方式
}
// 对于本地存储或临时URL生成失败的情况,使用服务器中转
storage := facades.Storage().Disk(attachment.Disk)
// 读取文件内容
content, err := storage.Get(attachment.Path)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"disk": attachment.Disk,
"path": attachment.Path,
})
}
// 设置响应头
mimeType := attachment.MimeType
if mimeType == "" {
mimeType = "application/octet-stream"
}
// 设置响应头
response := ctx.Response().
Header("Content-Type", mimeType).
Header("Content-Length", fmt.Sprintf("%d", len(content))).
Header("Cache-Control", "public, max-age=3600")
// 对于图片和视频,支持范围请求(Range request
if attachment.FileType == "image" || attachment.FileType == "video" {
response = response.Header("Accept-Ranges", "bytes")
}
return response.String(http.StatusOK, content)
}
// Destroy 删除附件
func (r *AttachmentController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if err := attachmentService.DeleteFile(attachment); err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"attachId": attachment.ID,
})
}
return response.Success(ctx)
}
type AttachmentBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除附件
func (r *AttachmentController) BatchDestroy(ctx http.Context) http.Response {
var req AttachmentBatchDestroyRequest
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 查询要删除的附件
attachmentService := services.NewAttachmentService(ctx)
attachments, err := attachmentService.GetByIDs(ids)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"ids": ids,
})
}
// 删除文件和记录
for _, attachment := range attachments {
if err := attachmentService.DeleteFile(&attachment); err != nil {
// 批量删除中单个文件删除失败只记录日志,不影响主流程
errorlog.RecordHTTP(ctx, "attachment", "Failed to delete attachment in batch delete", map[string]any{
"error": err.Error(),
"attachId": attachment.ID,
}, "Delete attachment in batch delete error: %v", err)
}
}
return response.Success(ctx)
}
// UpdateDisplayName 更新显示名称
func (r *AttachmentController) UpdateDisplayName(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
displayName := ctx.Request().Input("display_name", "")
if err := attachmentService.UpdateDisplayName(id, displayName); err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"attachId": id,
})
}
// 重新获取更新后的附件
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
return response.Success(ctx, http.Json{
"attachment": attachment,
})
}
@@ -0,0 +1,756 @@
package admin
import (
"encoding/json"
"strconv"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type AuthController struct {
authService services.AuthService
captchaService services.CaptchaService
googleAuthenticatorService services.GoogleAuthenticatorService
}
func NewAuthController() *AuthController {
adminService := services.NewAdminServiceImpl()
tokenService := services.NewTokenServiceImpl()
authService := services.NewAuthServiceImpl(adminService, tokenService)
return &AuthController{
authService: authService,
captchaService: services.NewCaptchaServiceImpl(),
googleAuthenticatorService: services.NewGoogleAuthenticatorServiceImpl(),
}
}
// getLoginRequestData 获取登录请求数据(排除敏感信息)
func (r *AuthController) getLoginRequestData(ctx http.Context) string {
inputs := make(map[string]any)
allInputs := ctx.Request().All()
for key, value := range allInputs {
// 使用工具函数检查是否是敏感字段
if utils.IsSensitiveField(key) {
inputs[key] = "***"
} else {
inputs[key] = value
}
}
if data, err := json.Marshal(inputs); err == nil {
return string(data)
}
return ""
}
// Login 管理员登录
func (r *AuthController) Login(ctx http.Context) http.Response {
var loginRequest admin.Login
errors, err := ctx.Request().ValidateRequest(&loginRequest)
if err != nil {
// 记录验证失败日志
requestData := r.getLoginRequestData(ctx)
r.authService.RecordLoginLog(ctx, 0, loginRequest.Username, 0, "validation_failed", requestData)
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
// 记录验证失败日志
requestData := r.getLoginRequestData(ctx)
r.authService.RecordLoginLog(ctx, 0, loginRequest.Username, 0, "validation_failed", requestData)
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 获取请求数据用于日志记录
requestData := r.getLoginRequestData(ctx)
// 先验证用户名是否存在
exists, err := facades.Orm().Query().Model(&models.Admin{}).Where("username", loginRequest.Username).Exists()
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"username": loginRequest.Username,
})
}
if !exists {
// 记录用户名不存在日志
r.authService.RecordLoginLog(ctx, 0, loginRequest.Username, 0, "username_not_found", requestData)
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUsernameOrPasswordErr.Code)
}
// 获取管理员信息
var admin models.Admin
if err := facades.Orm().Query().Where("username", loginRequest.Username).FirstOrFail(&admin); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"username": loginRequest.Username,
})
}
if admin.Status == 0 {
// 记录账号禁用日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, "account_disabled", requestData)
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAccountDisabled.Code)
}
// 验证密码
if !facades.Hash().Check(loginRequest.Password, admin.Password) {
// 记录登录失败日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, "password_error", requestData)
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUsernameOrPasswordErr.Code)
}
// 检查是否绑定了谷歌验证码
isBound, err := r.googleAuthenticatorService.IsBound(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 如果绑定了谷歌验证码,验证谷歌验证码
if isBound {
googleCode := loginRequest.GoogleCode
if googleCode == "" {
// 记录谷歌验证码缺失日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, "google_code_required", requestData)
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeRequired.Code)
}
// 获取管理员的密钥
secret, err := r.googleAuthenticatorService.GetSecret(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 验证谷歌验证码
if !r.googleAuthenticatorService.Verify(secret, googleCode) {
// 记录登录失败日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, "google_code_error", requestData)
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeInvalid.Code)
}
} else {
// 如果没有绑定谷歌验证码,验证图形验证码(如果启用了)
if r.captchaService.Enabled() {
captchaID := ctx.Request().Input("captcha_id")
captchaAnswer := ctx.Request().Input("captcha_answer")
if ok, messageKey := r.captchaService.Verify(captchaID, captchaAnswer); !ok {
if messageKey == "" {
messageKey = "captcha_invalid"
}
// 记录验证码错误日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, messageKey, requestData)
return response.Error(ctx, http.StatusBadRequest, messageKey)
}
}
}
// 验证通过,生成token并完成登录
// 获取浏览器和操作系统信息
browser, os := helpers.GetBrowserAndOS(ctx)
// 获取真实IP地址
ip := helpers.GetRealIP(ctx)
// 生成token
var expiresAt *time.Time
ttl := facades.Config().GetInt("jwt.ttl", 60) // 默认60分钟
if ttl > 0 {
exp := time.Now().Add(time.Duration(ttl) * time.Minute)
expiresAt = &exp
}
tokenService := services.NewTokenServiceImpl()
plainToken, _, err := tokenService.CreateToken("admin", admin.ID, "admin-token", expiresAt, browser, ip, os, "")
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
token := plainToken
// 记录登录成功日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 1, "login_success", requestData)
// 更新最后登录时间(ORM会自动更新UpdatedAt
facades.Orm().Query().Save(&admin)
return response.SuccessWithHeader(ctx, "login_success", "Authorization", "Bearer "+token, http.Json{
"token": token,
"admin": http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
},
})
}
// Captcha 获取登录验证码
func (r *AuthController) Captcha(ctx http.Context) http.Response {
enabled := r.captchaService.Enabled()
captchaData := http.Json{
"enabled": enabled,
}
if enabled {
captchaID, image, err := r.captchaService.Generate()
if err != nil {
return response.ErrorWithLog(ctx, "captcha", err)
}
captchaData["captcha_id"] = captchaID
captchaData["captcha_image"] = image
// captchaData["captcha_image"] = "data:image/png;base64," + image
}
return response.Success(ctx, http.Json{
"captcha": captchaData,
})
}
// Info 获取当前登录管理员信息
func (r *AuthController) Info(ctx http.Context) http.Response {
admin, permissions, menus, err := r.authService.GetAdminInfo(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 获取配置:是否显示无权限的按钮
showButtonsWithoutPermission := facades.Config().GetBool("admin.show_buttons_without_permission", false)
// 检查是否是超级管理员
const SuperAdminRoleSlug = "super-admin"
isSuperAdmin := false
for _, role := range admin.Roles {
if role.Slug == SuperAdminRoleSlug && role.Status == 1 {
isSuperAdmin = true
break
}
}
return response.Success(ctx, http.Json{
"admin": http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"email": admin.Email,
"phone": admin.Phone,
"department_id": admin.DepartmentID,
"department": admin.Department,
"roles": admin.Roles,
"permissions": permissions,
"menus": menus,
"is_super_admin": isSuperAdmin,
},
"config": http.Json{
"show_buttons_without_permission": showButtonsWithoutPermission,
"monitor_hidden": facades.Config().GetString("admin.monitor_hidden", ""),
},
})
}
// UpdateProfile 更新个人信息
func (r *AuthController) UpdateProfile(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
// 尝试值类型
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
// 尝试指针类型
if adminPtr == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 重新查询admin以确保获取最新数据
if err := facades.Orm().Query().Where("id", admin.ID).FirstOrFail(&admin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
nickname := ctx.Request().Input("nickname")
email := ctx.Request().Input("email")
phone := ctx.Request().Input("phone")
avatar := ctx.Request().Input("avatar")
if nickname != "" {
admin.Nickname = nickname
}
if email != "" {
admin.Email = email
}
if phone != "" {
admin.Phone = phone
}
if avatar != "" {
admin.Avatar = avatar
}
if err := facades.Orm().Query().Save(&admin); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 重新加载关联数据(确保部门和角色被正确加载)
var adminWithRelations models.Admin
if err := facades.Orm().Query().With("Department").With("Roles").Where("id", admin.ID).FirstOrFail(&adminWithRelations); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
admin = adminWithRelations
return response.Success(ctx, http.Json{
"admin": http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"email": admin.Email,
"phone": admin.Phone,
"department_id": admin.DepartmentID,
"department": admin.Department,
"roles": admin.Roles,
},
})
}
// Refresh 刷新Token
// 注意:此接口需要在JWT中间件之前调用,或者使用特殊的中间件处理
// 因为Refresh方法需要token过期但仍在刷新窗口内才能工作
func (r *AuthController) Refresh(ctx http.Context) http.Response {
// 从请求头获取token
token := ctx.Request().Header("Authorization", "")
if token == "" {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUnauthorized.Code)
}
// 移除Bearer前缀
token = str.Of(token).ChopStart("Bearer ").Trim().String()
// 先尝试解析token,如果token有效,直接重新生成(滑动过期)
if _, err := facades.Auth(ctx).Guard("admin").Parse(token); err == nil {
// Token有效,重新生成新token(延长过期时间)
if userID, err := facades.Auth(ctx).Guard("admin").ID(); err == nil {
if newToken, err := facades.Auth(ctx).Guard("admin").LoginUsingID(userID); err == nil {
return response.SuccessWithHeader(ctx, "token_refresh_success", "Authorization", "Bearer "+newToken, http.Json{
"token": newToken,
})
}
}
}
// 如果token已过期,尝试刷新(需要在刷新窗口内)
newToken, err := facades.Auth(ctx).Guard("admin").Refresh()
if err != nil {
// 刷新失败,返回错误
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrTokenRefreshFailed.Code)
}
// 刷新成功,返回新token
return response.SuccessWithHeader(ctx, "token_refresh_success", "Authorization", "Bearer "+newToken, http.Json{
"token": newToken,
})
}
// Heartbeat 心跳接口,用于更新用户的最后活跃时间
// JWT中间件会自动更新 last_used_at,这个接口只是确保用户在线状态
func (r *AuthController) Heartbeat(ctx http.Context) http.Response {
// JWT中间件已经更新了 last_used_at,这里只需要返回成功即可
return response.Success(ctx, "heartbeat_success")
}
// Logout 退出登录
func (r *AuthController) Logout(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue != nil {
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
if adminPtr != nil {
admin = *adminPtr
}
}
if admin.ID > 0 {
// 获取token
token := ctx.Request().Header("Authorization", "")
token = str.Of(token).ChopStart("Bearer ").Trim().String()
if token != "" {
// 删除token
tokenService := services.NewTokenServiceImpl()
_ = tokenService.DeleteToken(token)
}
// 记录退出日志
logoutRequestData := r.getLoginRequestData(ctx)
r.authService.RecordLoginLog(ctx, admin.ID, admin.Username, 1, "logout_success", logoutRequestData)
}
}
return response.Success(ctx, "logout_success")
}
// Tokens 获取当前用户的所有token列表
func (r *AuthController) Tokens(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin, ok := adminValue.(models.Admin)
if !ok {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 获取用户的所有token
tokenService := services.NewTokenServiceImpl()
tokens, err := tokenService.GetTokensByUser("admin", admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 获取当前使用的token
currentTokenValue := ctx.Value("token")
var currentTokenID uint
if currentTokenValue != nil {
if currentToken, ok := currentTokenValue.(models.PersonalAccessToken); ok {
currentTokenID = currentToken.ID
} else if currentTokenPtr, ok := currentTokenValue.(*models.PersonalAccessToken); ok {
if currentTokenPtr != nil {
currentTokenID = currentTokenPtr.ID
}
}
}
// 格式化token列表
var tokenList []http.Json
for _, token := range tokens {
tokenData := http.Json{
"id": token.ID,
"name": token.Name,
"last_used_at": token.LastUsedAt,
"expires_at": token.ExpiresAt,
"created_at": token.CreatedAt,
"is_current": token.ID == currentTokenID,
}
tokenList = append(tokenList, tokenData)
}
return response.Success(ctx, http.Json{
"tokens": tokenList,
})
}
// RevokeToken 删除指定的token(踢出指定设备)
func (r *AuthController) RevokeToken(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin, ok := adminValue.(models.Admin)
if !ok {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 获取要删除的token ID
tokenIDStr := ctx.Request().Route("id")
if tokenIDStr == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrTokenIDRequired.Code)
}
tokenID, err := strconv.ParseUint(tokenIDStr, 10, 32)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTokenID.Code)
}
// 查询token是否存在且属于当前用户
var token models.PersonalAccessToken
if err := facades.Orm().Query().
Where("id", tokenID).
Where("tokenable_type", "admin").
Where("tokenable_id", admin.ID).
First(&token); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrTokenNotFound.Code)
}
// 删除token(直接通过ID删除,因为数据库中存储的是hash值,无法获取原始token)
_, err = facades.Orm().Query().Delete(&token)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"token_id": token.ID,
"admin_id": admin.ID,
})
}
return response.Success(ctx, "revoke_success")
}
// RevokeAllTokens 删除当前用户的所有token(踢出所有设备)
func (r *AuthController) RevokeAllTokens(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin, ok := adminValue.(models.Admin)
if !ok {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 删除用户的所有token
tokenService := services.NewTokenServiceImpl()
if err := tokenService.DeleteTokensByUser("admin", admin.ID); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "revoke_all_success")
}
// KickOutUser 踢出指定用户的所有token(管理员操作)
func (r *AuthController) KickOutUser(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 获取要踢出的用户ID
userIDStr := ctx.Request().Route("id")
if userIDStr == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrUserIDRequired.Code)
}
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidUserID.Code)
}
// 查询用户是否存在
var targetAdmin models.Admin
if err := facades.Orm().Query().Where("id", userID).FirstOrFail(&targetAdmin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrUserNotFound.Code)
}
// 删除用户的所有token
tokenService := services.NewTokenServiceImpl()
if err := tokenService.DeleteTokensByUser("admin", targetAdmin.ID); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"target_user_id": targetAdmin.ID,
"operator_id": admin.ID,
})
}
return response.Success(ctx, "kick_out_success")
}
// GetGoogleAuthenticatorQRCode 获取谷歌验证码二维码(用于绑定)
func (r *AuthController) GetGoogleAuthenticatorQRCode(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 检查是否已经绑定
isBound, err := r.googleAuthenticatorService.IsBound(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
if isBound {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleAuthenticatorAlreadyBound.Code)
}
// 生成密钥和二维码
accountName := admin.Username
if admin.Email != "" {
accountName = admin.Email
}
secret, qrCodeURL, err := r.googleAuthenticatorService.GenerateSecret(accountName)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 生成二维码图片
qrCodeImage, err := r.googleAuthenticatorService.GenerateQRCodeImage(accountName, secret)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, http.Json{
"secret": secret,
"qr_code_url": qrCodeURL,
"qr_code_image": qrCodeImage,
})
}
// BindGoogleAuthenticator 绑定谷歌验证码
func (r *AuthController) BindGoogleAuthenticator(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
secret := ctx.Request().Input("secret")
code := ctx.Request().Input("code")
if secret == "" || code == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrSecretAndCodeRequired.Code)
}
// 绑定谷歌验证码
if err := r.googleAuthenticatorService.Bind(admin.ID, secret, code); err != nil {
if err.Error() == "invalid_code" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeInvalid.Code)
}
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "bind_success")
}
// UnbindGoogleAuthenticator 解绑谷歌验证码
func (r *AuthController) UnbindGoogleAuthenticator(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 需要验证码确认
code := ctx.Request().Input("code")
if code == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrCodeRequired.Code)
}
// 获取管理员的密钥
secret, err := r.googleAuthenticatorService.GetSecret(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
if secret == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleAuthenticatorNotBound.Code)
}
// 验证验证码
if !r.googleAuthenticatorService.Verify(secret, code) {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeInvalid.Code)
}
// 解绑谷歌验证码
if err := r.googleAuthenticatorService.Unbind(admin.ID); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "unbind_success")
}
// GetGoogleAuthenticatorStatus 获取谷歌验证码绑定状态
func (r *AuthController) GetGoogleAuthenticatorStatus(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 检查是否绑定
isBound, err := r.googleAuthenticatorService.IsBound(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, http.Json{
"is_bound": isBound,
})
}
@@ -0,0 +1,188 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type BlacklistController struct {
blacklistService services.BlacklistService
}
func NewBlacklistController() *BlacklistController {
return &BlacklistController{
blacklistService: services.NewBlacklistService(),
}
}
// findBlacklistByID 根据ID查找黑名单,如果不存在则返回错误响应
func (r *BlacklistController) findBlacklistByID(ctx http.Context, id uint) (*models.Blacklist, http.Response) {
blacklist, err := r.blacklistService.GetByID(id)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
return blacklist, nil
}
// buildFilters 构建查询过滤器
func (r *BlacklistController) buildFilters(ctx http.Context) services.BlacklistFilters {
ip := ctx.Request().Query("ip", "")
status := ctx.Request().Query("status", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.BlacklistFilters{
IP: ip,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 黑名单列表
func (r *BlacklistController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
blacklists, total, err := r.blacklistService.GetList(filters, page, pageSize)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": blacklists,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 黑名单详情
func (r *BlacklistController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
blacklist, resp := r.findBlacklistByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"blacklist": *blacklist,
})
}
// Store 创建黑名单
func (r *BlacklistController) Store(ctx http.Context) http.Response {
// 使用请求验证
var blacklistCreate adminrequests.BlacklistCreate
errors, err := ctx.Request().ValidateRequest(&blacklistCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 验证IP格式(使用自定义验证函数)
if err := utils.ValidateBlacklistIP(blacklistCreate.IP); err != nil {
// 使用业务错误类型,直接提取错误码
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
// 如果不是业务错误,返回通用错误
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidIPFormat.Code)
}
blacklist, err := r.blacklistService.Create(
blacklistCreate.IP,
blacklistCreate.Remark,
blacklistCreate.Status,
)
if err != nil {
return response.ErrorWithLog(ctx, "blacklist", err, map[string]any{
"ip": blacklistCreate.IP,
})
}
return response.Success(ctx, http.Json{
"blacklist": blacklist,
})
}
// Update 更新黑名单
func (r *BlacklistController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
blacklist, resp := r.findBlacklistByID(ctx, id)
if resp != nil {
return resp
}
// 使用请求验证
var blacklistUpdate adminrequests.BlacklistUpdate
errors, err := ctx.Request().ValidateRequest(&blacklistUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["ip"]; exists {
// 验证IP格式(使用自定义验证函数)
if err := utils.ValidateBlacklistIP(blacklistUpdate.IP); err != nil {
// 使用业务错误类型,直接提取错误码
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
// 如果不是业务错误,返回通用错误
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidIPFormat.Code)
}
blacklist.IP = blacklistUpdate.IP
}
if _, exists := allInputs["remark"]; exists {
blacklist.Remark = blacklistUpdate.Remark
}
if _, exists := allInputs["status"]; exists {
blacklist.Status = blacklistUpdate.Status
}
if err := r.blacklistService.Update(blacklist); err != nil {
return response.ErrorWithLog(ctx, "blacklist", err, map[string]any{
"blacklist_id": blacklist.ID,
})
}
return response.Success(ctx, http.Json{
"blacklist": *blacklist,
})
}
// Destroy 删除黑名单
func (r *BlacklistController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
blacklist, resp := r.findBlacklistByID(ctx, id)
if resp != nil {
return resp
}
if err := r.blacklistService.Delete(blacklist); err != nil {
return response.ErrorWithLog(ctx, "blacklist", err, map[string]any{
"blacklist_id": blacklist.ID,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,153 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/response"
"goravel/app/services"
)
type CodeGeneratorController struct {
codeGeneratorService services.CodeGeneratorService
}
type GenerateRequest struct {
ModuleName string `json:"module_name"`
TableName string `json:"table_name"`
Fields []services.FieldConfig `json:"fields"`
Files []string `json:"files"`
Options map[string]bool `json:"options"`
}
type PreviewRequest struct {
ModuleName string `json:"module_name"`
TableName string `json:"table_name"`
Fields []services.FieldConfig `json:"fields"`
FileType string `json:"file_type"`
Options map[string]bool `json:"options"`
}
type SaveRequest struct {
ModuleName string `json:"module_name"`
TableName string `json:"table_name"`
Fields []services.FieldConfig `json:"fields"`
Force bool `json:"force"`
Files []string `json:"files"`
Options map[string]bool `json:"options"`
}
func NewCodeGeneratorController() *CodeGeneratorController {
return &CodeGeneratorController{
codeGeneratorService: services.NewCodeGeneratorService(),
}
}
// Generate 生成CRUD代码
func (c *CodeGeneratorController) Generate(ctx http.Context) http.Response {
var req GenerateRequest
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_fields")
}
if req.ModuleName == "" {
return response.Error(ctx, http.StatusBadRequest, "module_name_required")
}
if req.TableName == "" {
return response.Error(ctx, http.StatusBadRequest, "table_name_required")
}
files, err := c.codeGeneratorService.Generate(req.ModuleName, req.TableName, req.Fields, req.Files, req.Options)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"files": files,
})
}
// Preview 预览生成的代码
func (c *CodeGeneratorController) Preview(ctx http.Context) http.Response {
var req PreviewRequest
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_fields")
}
if req.ModuleName == "" {
return response.Error(ctx, http.StatusBadRequest, "module_name_required")
}
if req.TableName == "" {
return response.Error(ctx, http.StatusBadRequest, "table_name_required")
}
if req.FileType == "" {
return response.Error(ctx, http.StatusBadRequest, "file_type_required")
}
code, err := c.codeGeneratorService.Preview(req.ModuleName, req.TableName, req.Fields, req.FileType, req.Options)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"code": code,
})
}
// Save 保存生成的代码到文件系统
func (c *CodeGeneratorController) Save(ctx http.Context) http.Response {
var req SaveRequest
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_fields")
}
if req.ModuleName == "" {
return response.Error(ctx, http.StatusBadRequest, "module_name_required")
}
if req.TableName == "" {
return response.Error(ctx, http.StatusBadRequest, "table_name_required")
}
var savedFiles []string
var err error
if req.Force {
savedFiles, err = c.codeGeneratorService.ForceSave(req.ModuleName, req.TableName, req.Fields, req.Files, req.Options)
} else {
savedFiles, err = c.codeGeneratorService.Save(req.ModuleName, req.TableName, req.Fields, req.Files, req.Options)
}
if err != nil {
if filesExistErr, ok := err.(*services.FilesExistError); ok {
return ctx.Response().Json(409, http.Json{
"code": 409,
"message": facades.Lang(ctx).Get("files_exist"),
"error_code": "files_exist",
"files": filesExistErr.Files,
})
}
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"saved_files": savedFiles,
})
}
// GetFieldTypes 获取支持的字段类型
func (c *CodeGeneratorController) GetFieldTypes(ctx http.Context) http.Response {
fieldTypes := c.codeGeneratorService.GetFieldTypes()
return response.Success(ctx, http.Json{
"field_types": fieldTypes,
})
}
@@ -0,0 +1,325 @@
package admin
import (
"crypto/tls"
"fmt"
"net/smtp"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/carbon"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/response"
"goravel/app/models"
)
type ConfigController struct {
}
func NewConfigController() *ConfigController {
return &ConfigController{}
}
// GetByGroup 根据分组获取配置
func (r *ConfigController) GetByGroup(ctx http.Context) http.Response {
group := ctx.Request().Route("group")
if group == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrConfigGroupRequired.Code)
}
var configs []models.Config
// 查询配置,即使没有数据也返回空数组,不返回错误
_ = facades.Orm().Query().Where("group", group).Order("sort asc, id asc").Get(&configs)
// 如果是邮箱配置分组,将密码字段的值设为空,不让前端看到
if group == "email" {
for i := range configs {
if configs[i].Key == "email_password" {
configs[i].Value = ""
}
}
}
return response.Success(ctx, http.Json{
"configs": configs,
})
}
// Save 保存配置(按分组批量保存)
func (r *ConfigController) Save(ctx http.Context) http.Response {
group := ctx.Request().Input("group")
if group == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrConfigGroupRequired.Code)
}
configsMap := ctx.Request().InputMap("configs")
if len(configsMap) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrConfigsRequired.Code)
}
// 获取该分组下的所有配置(即使查询失败也继续,使用空数组)
var existingConfigs []models.Config
_ = facades.Orm().Query().Where("group", group).Get(&existingConfigs)
// 创建key到config的映射
configMap := make(map[string]*models.Config)
for i := range existingConfigs {
configMap[existingConfigs[i].Key] = &existingConfigs[i]
}
now := carbon.Now()
// 对于 storage 分组,只允许保存驱动选择字段(白名单)
if group == "storage" {
allowedKeys := map[string]bool{
"file_disk": true,
"storage_disk": true, // 向后兼容,保留但不推荐使用
"export_disk": true, // 向后兼容
}
filteredConfigs := make(map[string]any)
for key, value := range configsMap {
if allowedKeys[key] {
filteredConfigs[key] = value
}
}
configsMap = filteredConfigs
}
// 批量处理配置更新和创建
for key, value := range configsMap {
// 转换值为字符串,处理布尔值
var valueStr string
switch v := value.(type) {
case bool:
if v {
valueStr = "1"
} else {
valueStr = "0"
}
case nil:
valueStr = ""
default:
valueStr = cast.ToString(value)
}
// 如果是邮箱配置的密码字段,且值为空,且配置已存在,则跳过更新(保持原有值)
if group == "email" && key == "email_password" && valueStr == "" {
if _, exists := configMap[key]; exists {
continue
}
// 如果配置不存在,则创建空值配置(允许首次创建时为空)
}
if config, exists := configMap[key]; exists {
// 更新现有配置
config.Value = valueStr
if err := facades.Orm().Query().Save(config); err != nil {
return response.ErrorWithLog(ctx, "config", err, map[string]any{
"group": group,
"key": key,
})
}
} else {
// 创建新配置
configData := map[string]any{
"group": group,
"key": key,
"value": valueStr,
"type": "input",
"sort": 0,
"created_at": now,
"updated_at": now,
}
if err := facades.Orm().Query().Table("configs").Create(configData); err != nil {
return response.ErrorWithLog(ctx, "config", err, map[string]any{
"group": group,
"key": key,
})
}
}
}
return response.Success(ctx)
}
// TestEmail 测试邮件发送
func (r *ConfigController) TestEmail(ctx http.Context) http.Response {
emailHost := ctx.Request().Input("email_host")
emailPort := cast.ToInt(ctx.Request().Input("email_port", "587"))
emailUsername := ctx.Request().Input("email_username")
emailPassword := ctx.Request().Input("email_password")
emailFrom := ctx.Request().Input("email_from")
emailFromName := ctx.Request().Input("email_from_name")
emailEncryption := ctx.Request().Input("email_encryption", "tls")
// 验证必填字段
if emailHost == "" || emailPort == 0 || emailUsername == "" || emailFrom == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrEmailConfigRequired.Code)
}
// 获取当前登录的管理员邮箱作为测试收件人
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin, ok := adminValue.(models.Admin)
if !ok {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 如果没有邮箱,使用发件人邮箱作为测试收件人
testEmail := emailFrom
if admin.Email != "" {
testEmail = admin.Email
}
// 构建邮件内容
fromName := emailFromName
if fromName == "" {
fromName = emailFrom
}
subject := "测试邮件"
body := fmt.Sprintf(`<h2>这是一封测试邮件</h2>
<p>如果您收到这封邮件,说明邮件配置正确。</p>
<p>发送时间:%s</p>
<p>SMTP服务器:%s:%d</p>
<p>加密方式:%s</p>`, carbon.Now().ToDateTimeString(), emailHost, emailPort, emailEncryption)
// 构建邮件消息
message := fmt.Sprintf("From: %s <%s>\r\n", fromName, emailFrom)
message += fmt.Sprintf("To: %s\r\n", testEmail)
message += fmt.Sprintf("Subject: %s\r\n", subject)
message += "MIME-Version: 1.0\r\n"
message += "Content-Type: text/html; charset=UTF-8\r\n"
message += "\r\n" + body
// 构建SMTP地址
addr := fmt.Sprintf("%s:%d", emailHost, emailPort)
// 创建SMTP认证
auth := smtp.PlainAuth("", emailUsername, emailPassword, emailHost)
// 发送邮件
var err error
if emailEncryption == "ssl" {
// SSL连接
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: emailHost,
}
conn, connErr := tls.Dial("tcp", addr, tlsConfig)
if connErr != nil {
return response.ErrorWithLog(ctx, "config", connErr, map[string]any{
"host": emailHost,
"port": emailPort,
})
}
defer conn.Close()
client, clientErr := smtp.NewClient(conn, emailHost)
if clientErr != nil {
return response.ErrorWithLog(ctx, "config", clientErr)
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = client.Mail(emailFrom); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = client.Rcpt(testEmail); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
writer, writerErr := client.Data()
if writerErr != nil {
return response.ErrorWithLog(ctx, "config", writerErr)
}
_, err = writer.Write([]byte(message))
if err != nil {
writer.Close()
return response.ErrorWithLog(ctx, "config", err)
}
err = writer.Close()
if err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
} else {
// TLS或普通连接
if emailEncryption == "tls" {
// TLS连接
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: emailHost,
}
err = smtp.SendMail(addr, auth, emailFrom, []string{testEmail}, []byte(message))
if err != nil {
// 如果直接SendMail失败,尝试手动TLS
conn, connErr := smtp.Dial(addr)
if connErr != nil {
return response.ErrorWithLog(ctx, "config", connErr, map[string]any{
"host": emailHost,
"port": emailPort,
})
}
defer conn.Close()
if err = conn.StartTLS(tlsConfig); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = conn.Auth(auth); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = conn.Mail(emailFrom); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = conn.Rcpt(testEmail); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
writer, writerErr := conn.Data()
if writerErr != nil {
return response.ErrorWithLog(ctx, "config", writerErr)
}
_, err = writer.Write([]byte(message))
if err != nil {
writer.Close()
return response.ErrorWithLog(ctx, "config", err)
}
err = writer.Close()
if err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
}
} else {
// 普通连接(无加密)
err = smtp.SendMail(addr, auth, emailFrom, []string{testEmail}, []byte(message))
}
}
if err != nil {
return response.ErrorWithLog(ctx, "config", err, map[string]any{
"host": emailHost,
"port": emailPort,
"from": emailFrom,
"to": testEmail,
})
}
return response.Success(ctx, "test_email_success", http.Json{
"message": "测试邮件已发送到 " + testEmail,
})
}
@@ -0,0 +1,454 @@
package admin
import (
"encoding/json"
"fmt"
nethttp "net/http"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type DashboardController struct{}
func NewDashboardController() *DashboardController {
return &DashboardController{}
}
// GetCount 获取统计数据
func (r *DashboardController) GetCount(ctx http.Context) http.Response {
countData := r.getCountData()
// 获取今日访问量(今日登录日志数)
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayEnd := todayStart.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
todayVisits, _ := facades.Orm().Query().Model(&models.LoginLog{}).
Where("created_at >= ?", todayStart).
Where("created_at <= ?", todayEnd).
Where("status", 1).
Count()
// 获取在线管理员数
onlineAdminCount := r.getOnlineAdminCount()
// 获取最近一年的订单总数
orderService := services.NewOrderService()
orderCountInYear, _ := orderService.GetOrdersCountInYear()
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": map[string]any{
"admin_count": countData["admins"],
"role_count": countData["roles"],
"menu_count": countData["menus"],
"today_visits": todayVisits,
"online_admins": onlineAdminCount,
"order_count_in_year": orderCountInYear,
},
})
}
// GetUserAccessSource 获取用户访问来源数据(根据 UserAgent 判断设备类型)
func (r *DashboardController) GetUserAccessSource(ctx http.Context) http.Response {
// 从登录日志中统计不同设备类型的访问量
// 统计最近30天的数据
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
var loginLogs []models.LoginLog
facades.Orm().Query().Model(&models.LoginLog{}).
Where("created_at >= ?", thirtyDaysAgo).
Where("status", 1).
Get(&loginLogs)
// 统计设备类型
deviceStats := make(map[string]int64)
for _, log := range loginLogs {
deviceType := r.parseDeviceType(log.UserAgent)
deviceStats[deviceType]++
}
// 转换为数组格式
result := []map[string]any{
{"name": "桌面端", "value": deviceStats["desktop"]},
{"name": "移动端", "value": deviceStats["mobile"]},
{"name": "平板端", "value": deviceStats["tablet"]},
{"name": "其他", "value": deviceStats["other"]},
}
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": result,
})
}
// parseDeviceType 根据 UserAgent 解析设备类型
func (r *DashboardController) parseDeviceType(userAgent string) string {
if userAgent == "" {
return "other"
}
ua := strings.ToLower(userAgent)
// 平板设备检测(需要在移动设备之前检测)
if strings.Contains(ua, "ipad") || (strings.Contains(ua, "tablet") && !strings.Contains(ua, "mobile")) {
return "tablet"
}
// 移动设备检测
if strings.Contains(ua, "mobile") || strings.Contains(ua, "android") || strings.Contains(ua, "iphone") {
return "mobile"
}
// 桌面设备
return "desktop"
}
// GetWeeklyUserActivity 获取每周用户活跃量(从操作日志统计)
func (r *DashboardController) GetWeeklyUserActivity(ctx http.Context) http.Response {
weeklyData := r.getWeeklyUserActivityData()
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": weeklyData,
})
}
// GetMonthlySales 获取每月操作统计(替换销售额数据)
func (r *DashboardController) GetMonthlySales(ctx http.Context) http.Response {
// 替换成操作日志月度统计
monthlyData := r.getMonthlyOperationData()
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": monthlyData,
})
}
// GetRecentActivities 获取最近活动
func (r *DashboardController) GetRecentActivities(ctx http.Context) http.Response {
// 获取最近10条操作日志
var logs []models.OperationLog
facades.Orm().Query().Model(&models.OperationLog{}).
With("Admin").
Order("id desc").
Limit(10).
Get(&logs)
activities := make([]map[string]any, 0, len(logs))
for _, log := range logs {
adminName := "未知用户"
if log.Admin.ID > 0 {
adminName = log.Admin.Nickname
if adminName == "" {
adminName = log.Admin.Username
}
}
statusText := "成功"
statusType := "success"
if log.Status == 0 {
statusText = "失败"
statusType = "danger"
}
// 计算时间差
var timeAgo string
if log.CreatedAt != nil {
// carbon.DateTime 转换为 time.Time
timeStr := log.CreatedAt.ToDateTimeString()
if t, err := utils.ParseDateTime(timeStr); err == nil {
timeAgo = r.formatTimeAgo(t)
} else {
timeAgo = "未知"
}
} else {
timeAgo = "未知"
}
activities = append(activities, map[string]any{
"user": adminName,
"action": log.Title,
"time": timeAgo,
"status": statusText,
"type": statusType,
"avatarColor": r.getAvatarColor(adminName),
})
}
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": activities,
})
}
// formatTimeAgo 格式化时间差
func (r *DashboardController) formatTimeAgo(t time.Time) string {
now := time.Now()
duration := now.Sub(t)
if duration < time.Minute {
return "刚刚"
} else if duration < time.Hour {
minutes := int(duration.Minutes())
return fmt.Sprintf("%d分钟前", minutes)
} else if duration < 24*time.Hour {
hours := int(duration.Hours())
return fmt.Sprintf("%d小时前", hours)
} else {
days := int(duration.Hours() / 24)
return fmt.Sprintf("%d天前", days)
}
}
// getAvatarColor 根据用户名生成头像颜色
func (r *DashboardController) getAvatarColor(name string) string {
colors := []string{"#409EFF", "#67C23A", "#E6A23C", "#F56C6C", "#909399", "#606266"}
if name == "" {
return colors[0]
}
hash := 0
for _, char := range name {
hash = hash*31 + int(char)
}
return colors[hash%len(colors)]
}
// StreamDashboardData SSE 实时推送 Dashboard 数据
// 定期推送所有 Dashboard 统计数据,包括计数、用户来源、用户活跃度、销售额等
func (r *DashboardController) StreamDashboardData(ctx http.Context) http.Response {
// 获取推送间隔(秒),默认 5 秒
interval := 5
if intervalStr := ctx.Request().Query("interval", ""); intervalStr != "" {
if parsed, err := time.ParseDuration(intervalStr + "s"); err == nil {
interval = int(parsed.Seconds())
if interval < 2 {
interval = 2
}
if interval > 60 {
interval = 60
}
}
}
// 设置 SSE 响应头
writer := ctx.Response().Writer()
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
writer.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲
// 发送初始连接消息
initMsg := map[string]any{
"type": "connected",
"message": "SSE连接已建立,开始推送 Dashboard 数据",
"interval": interval,
}
initData, _ := json.Marshal(initMsg)
fmt.Fprintf(writer, "data: %s\n\n", string(initData))
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
// 创建 ticker,定期推送数据
ticker := time.NewTicker(time.Duration(interval) * time.Second)
defer ticker.Stop()
// 检测客户端断开连接
clientGone := ctx.Request().Origin().Context().Done()
for {
select {
case <-clientGone:
// 客户端断开连接
return nil
case <-ticker.C:
// 收集所有 Dashboard 数据
dashboardData := r.collectDashboardData(ctx)
// 构造 SSE 消息
message := map[string]any{
"type": "dashboard_data",
"data": dashboardData,
"timestamp": time.Now().Format(time.RFC3339),
}
messageData, err := json.Marshal(message)
if err != nil {
// 记录错误但继续推送
facades.Log().Errorf("Dashboard SSE: failed to marshal data: %v", err)
continue
}
// 发送 SSE 消息
fmt.Fprintf(writer, "data: %s\n\n", string(messageData))
// 刷新缓冲区
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
}
}
}
// collectDashboardData 收集 Dashboard 数据
func (r *DashboardController) collectDashboardData(ctx http.Context) map[string]any {
data := make(map[string]any)
// 1. 获取统计数据(管理员、角色、权限等)
countData := r.getCountData()
data["count"] = countData
// 2. 获取用户访问来源数据
accessSourceData := r.getUserAccessSourceData()
data["user_access_source"] = accessSourceData
// 3. 获取每周用户活跃量
weeklyActivityData := r.getWeeklyUserActivityData()
data["weekly_user_activity"] = weeklyActivityData
// 4. 获取每月销售额
monthlySalesData := r.getMonthlySalesData()
data["monthly_sales"] = monthlySalesData
// 5. 获取在线管理员数
onlineAdminCount := r.getOnlineAdminCount()
data["online_admin_count"] = onlineAdminCount
return data
}
// getCountData 获取统计数据
func (r *DashboardController) getCountData() map[string]any {
// 统计各种数据
adminCount, _ := facades.Orm().Query().Model(&models.Admin{}).Count()
roleCount, _ := facades.Orm().Query().Model(&models.Role{}).Count()
permissionCount, _ := facades.Orm().Query().Model(&models.Permission{}).Count()
menuCount, _ := facades.Orm().Query().Model(&models.Menu{}).Count()
departmentCount, _ := facades.Orm().Query().Model(&models.Department{}).Count()
dictionaryCount, _ := facades.Orm().Query().Model(&models.Dictionary{}).Count()
configCount, _ := facades.Orm().Query().Model(&models.Config{}).Count()
return map[string]any{
"admins": adminCount,
"roles": roleCount,
"permissions": permissionCount,
"menus": menuCount,
"departments": departmentCount,
"dictionaries": dictionaryCount,
"configs": configCount,
}
}
// getUserAccessSourceData 获取用户访问来源数据
func (r *DashboardController) getUserAccessSourceData() []map[string]any {
// 这里可以根据实际业务逻辑查询用户访问来源
// 例如:根据登录日志统计不同来源的用户数
// 暂时返回示例数据
return []map[string]any{
{"source": "web", "count": 0},
{"source": "mobile", "count": 0},
{"source": "api", "count": 0},
}
}
// getWeeklyUserActivityData 获取每周用户活跃量(从操作日志统计)
func (r *DashboardController) getWeeklyUserActivityData() []map[string]any {
now := time.Now()
weeklyData := make([]map[string]any, 7)
for i := 6; i >= 0; i-- {
date := now.AddDate(0, 0, -i)
dateStr := utils.FormatDate(date)
// 计算当天的开始和结束时间
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
endOfDay := startOfDay.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
// 统计当天的操作日志数(访问量)
visitCount, _ := facades.Orm().Query().Model(&models.OperationLog{}).
Where("created_at >= ?", startOfDay).
Where("created_at <= ?", endOfDay).
Where("status", 1).
Count()
// 统计当天活跃的管理员数(去重)
var uniqueAdmins []uint
facades.Orm().Query().Model(&models.OperationLog{}).
Where("created_at >= ?", startOfDay).
Where("created_at <= ?", endOfDay).
Where("status", 1).
Select("DISTINCT admin_id").
Pluck("admin_id", &uniqueAdmins)
userCount := int64(len(uniqueAdmins))
weeklyData[6-i] = map[string]any{
"date": dateStr,
"visits": visitCount,
"users": userCount,
}
}
return weeklyData
}
// getMonthlySalesData 获取每月销售额(保留方法名以兼容 SSE)
func (r *DashboardController) getMonthlySalesData() []map[string]any {
return r.getMonthlyOperationData()
}
// getMonthlyOperationData 获取每月操作统计(替换销售额)
func (r *DashboardController) getMonthlyOperationData() []map[string]any {
now := time.Now()
monthlyData := make([]map[string]any, 12)
for i := 11; i >= 0; i-- {
date := now.AddDate(0, -i, 0)
monthStr := date.Format("2006-01")
// 计算当月的开始和结束时间
startOfMonth := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
var endOfMonth time.Time
if i == 0 {
// 当前月,使用当前时间
endOfMonth = now
} else {
// 历史月份,使用月末
endOfMonth = startOfMonth.AddDate(0, 1, -1).Add(23*time.Hour + 59*time.Minute + 59*time.Second)
}
// 统计当月的操作日志数
operationCount, _ := facades.Orm().Query().Model(&models.OperationLog{}).
Where("created_at >= ?", startOfMonth).
Where("created_at <= ?", endOfMonth).
Where("status", 1).
Count()
monthlyData[11-i] = map[string]any{
"month": monthStr,
"count": operationCount,
}
}
return monthlyData
}
// getOnlineAdminCount 获取在线管理员数
func (r *DashboardController) getOnlineAdminCount() int64 {
// 统计最近15分钟内有活动的管理员(在线管理员)
onlineThreshold := time.Now().Add(-15 * time.Minute)
count, _ := facades.Orm().Query().Model(&models.PersonalAccessToken{}).
Where("tokenable_type", "admin").
Where("last_used_at IS NOT NULL").
Where("last_used_at >= ?", onlineThreshold).
Count()
return count
}
@@ -0,0 +1,99 @@
package admin
import (
"fmt"
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/response"
"goravel/app/utils/errorlog"
"goravel/app/utils/traceid"
)
type DebugController struct {
}
func NewDebugController() *DebugController {
return &DebugController{}
}
// TraceTest 手动触发不同级别的日志,方便校验 trace_id
// 支持 query 参数:
// - level: 日志级别 (error/warning/info/debug),默认 error
// - message: 自定义消息,默认 "manual trace log test"
// - trace_id: 自定义 trace_id(可选)
func (r *DebugController) TraceTest(ctx http.Context) http.Response {
traceID := traceid.EnsureHTTPContext(ctx, ctx.Request().Query("trace_id", ""))
message := ctx.Request().Query("message", "manual trace log test")
level := ctx.Request().Query("level", "error")
// 根据级别记录不同级别的日志
switch level {
case "warning":
errorlog.RecordHTTPWithLevel(ctx, "warning", "trace-test", "Trace test warning log", map[string]any{
"path": ctx.Request().Path(),
"method": ctx.Request().Method(),
"trace_id": traceID,
"message": message,
"level": "warning",
}, "Trace test warning: %s", message)
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrTraceTestWarning.Code)
case "info":
errorlog.RecordHTTPWithLevel(ctx, "info", "trace-test", "Trace test info log", map[string]any{
"path": ctx.Request().Path(),
"method": ctx.Request().Method(),
"trace_id": traceID,
"message": message,
"level": "info",
}, "Trace test info: %s", message)
return response.Success(ctx, "trace_test_info", http.Json{
"message": message,
"level": "info",
"hint": "Check system logs with this trace_id to see info level log",
})
case "debug":
errorlog.RecordHTTPWithLevel(ctx, "debug", "trace-test", "Trace test debug log", map[string]any{
"path": ctx.Request().Path(),
"method": ctx.Request().Method(),
"trace_id": traceID,
"message": message,
"level": "debug",
}, "Trace test debug: %s", message)
return response.Success(ctx, "trace_test_debug", http.Json{
"message": message,
"level": "debug",
"hint": "Check system logs with this trace_id to see debug level log",
})
default: // error
errorlog.RecordHTTP(ctx, "trace-test", "Trace test error log", map[string]any{
"path": ctx.Request().Path(),
"method": ctx.Request().Method(),
"trace_id": traceID,
"message": message,
"level": "error",
}, "Trace test error: %s", message)
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrTraceTestError.Code)
}
}
// TestErrorLog 测试 ErrorWithLog 日志记录功能
// 用于手动触发异常,测试日志是否正常写入
func (r *MenuController) TestErrorLog(ctx http.Context) http.Response {
// 创建一个测试错误
testErr := fmt.Errorf("测试错误日志记录功能 - 这是一个手动触发的异常测试")
return response.ErrorWithLog(ctx, "menu-test", testErr)
// return response.ErrorWithLog(ctx, "menu-test", testErr, map[string]any{
// "test_type": "manual_test",
// "test_purpose": "验证 ErrorWithLog 是否能正确写入日志记录",
// "test_time": carbon.Now().ToDateTimeString(),
// "controller": "MenuController",
// "method": "TestErrorLog",
// })
}
@@ -0,0 +1,227 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type DepartmentController struct {
treeService services.TreeService
departmentService services.DepartmentService
}
func NewDepartmentController() *DepartmentController {
treeService := services.NewTreeServiceImpl()
return &DepartmentController{
treeService: treeService,
departmentService: services.NewDepartmentServiceImpl(treeService),
}
}
// findDepartmentByID 根据ID查找部门,如果不存在则返回错误响应
func (r *DepartmentController) findDepartmentByID(ctx http.Context, id uint) (*models.Department, http.Response) {
department, err := r.departmentService.GetByID(id)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrDepartmentNotFound.Code)
}
return department, nil
}
// buildFilters 构建查询过滤器
func (r *DepartmentController) buildFilters(ctx http.Context) services.DepartmentFilters {
name := ctx.Request().Query("name", "")
status := ctx.Request().Query("status", "")
// 使用辅助函数自动转换时区
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.DepartmentFilters{
Name: name,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 部门列表(树形结构)
func (r *DepartmentController) Index(ctx http.Context) http.Response {
name := ctx.Request().Query("name", "")
status := ctx.Request().Query("status", "")
// 使用辅助函数自动转换时区
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
// 如果有搜索条件,返回扁平列表;否则返回树形结构
if name != "" || status != "" || startTime != "" || endTime != "" {
filters := r.buildFilters(ctx)
// 搜索时获取所有匹配的记录,不限制分页
departments, _, err := r.departmentService.GetList(filters, 1, 10000)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"list": departments,
})
}
// 无搜索条件时返回树形结构
departments, err := r.treeService.BuildDepartmentTree(0)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"list": departments,
})
}
// Show 部门详情
func (r *DepartmentController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
department, resp := r.findDepartmentByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"department": *department,
})
}
// Store 创建部门
func (r *DepartmentController) Store(ctx http.Context) http.Response {
// 使用请求验证
var departmentCreate adminrequests.DepartmentCreate
errors, err := ctx.Request().ValidateRequest(&departmentCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
department, err := r.departmentService.Create(
departmentCreate.ParentID,
departmentCreate.Name,
departmentCreate.Code,
departmentCreate.Leader,
departmentCreate.Phone,
departmentCreate.Email,
departmentCreate.Remark,
departmentCreate.Status,
departmentCreate.Sort,
)
if err != nil {
return response.ErrorWithLog(ctx, "department", err, map[string]any{
"name": departmentCreate.Name,
})
}
return response.Success(ctx, http.Json{
"department": department,
})
}
// Update 更新部门
func (r *DepartmentController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
department, resp := r.findDepartmentByID(ctx, id)
if resp != nil {
return resp
}
// 使用请求验证
var departmentUpdate adminrequests.DepartmentUpdate
errors, err := ctx.Request().ValidateRequest(&departmentUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["name"]; exists {
department.Name = departmentUpdate.Name
}
if _, exists := allInputs["parent_id"]; exists {
department.ParentID = departmentUpdate.ParentID
}
if _, exists := allInputs["code"]; exists {
department.Code = departmentUpdate.Code
}
if _, exists := allInputs["leader"]; exists {
department.Leader = departmentUpdate.Leader
}
if _, exists := allInputs["phone"]; exists {
department.Phone = departmentUpdate.Phone
}
if _, exists := allInputs["email"]; exists {
department.Email = departmentUpdate.Email
}
if _, exists := allInputs["status"]; exists {
department.Status = departmentUpdate.Status
}
if _, exists := allInputs["sort"]; exists {
department.Sort = departmentUpdate.Sort
}
if _, exists := allInputs["remark"]; exists {
department.Remark = departmentUpdate.Remark
}
if err := r.departmentService.Update(department); err != nil {
return response.ErrorWithLog(ctx, "department", err, map[string]any{
"department_id": department.ID,
})
}
return response.Success(ctx, http.Json{
"department": *department,
})
}
// Destroy 删除部门
func (r *DepartmentController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
department, resp := r.findDepartmentByID(ctx, id)
if resp != nil {
return resp
}
// 检查是否有子部门
hasChildren, err := r.treeService.HasDepartmentChildren(id)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
if hasChildren {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrDepartmentHasChildren.Code)
}
// 检查是否有管理员
hasAdmins, err := r.departmentService.HasAdmins(id)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
if hasAdmins {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrDepartmentHasAdmins.Code)
}
if err := r.departmentService.Delete(department); err != nil {
return response.ErrorWithLog(ctx, "department", err, map[string]any{
"department_id": department.ID,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,215 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type DictionaryController struct {
dictionaryService services.DictionaryService
}
func NewDictionaryController() *DictionaryController {
return &DictionaryController{
dictionaryService: services.NewDictionaryService(),
}
}
// findDictionaryByID 根据ID查找字典,如果不存在则返回错误响应
func (r *DictionaryController) findDictionaryByID(ctx http.Context, id uint) (*models.Dictionary, http.Response) {
dictionary, err := r.dictionaryService.GetByID(id)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrDictionaryNotFound.Code)
}
return dictionary, nil
}
// buildFilters 构建查询过滤器
func (r *DictionaryController) buildFilters(ctx http.Context) services.DictionaryFilters {
dictType := ctx.Request().Query("type", "")
status := ctx.Request().Query("status", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.DictionaryFilters{
Type: dictType,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 字典列表
func (r *DictionaryController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
dictionaries, total, err := r.dictionaryService.GetList(filters, page, pageSize)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": dictionaries,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 字典详情
func (r *DictionaryController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
dictionary, resp := r.findDictionaryByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"dictionary": *dictionary,
})
}
// Store 创建字典
func (r *DictionaryController) Store(ctx http.Context) http.Response {
// 使用请求验证
var dictionaryCreate adminrequests.DictionaryCreate
errors, err := ctx.Request().ValidateRequest(&dictionaryCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
dictionary, err := r.dictionaryService.Create(
dictionaryCreate.Type,
dictionaryCreate.Label,
dictionaryCreate.Value,
dictionaryCreate.TranslationKey,
dictionaryCreate.Description,
dictionaryCreate.Remark,
dictionaryCreate.Status,
dictionaryCreate.Sort,
)
if err != nil {
return response.ErrorWithLog(ctx, "dictionary", err, map[string]any{
"type": dictionaryCreate.Type,
"label": dictionaryCreate.Label,
})
}
return response.Success(ctx, http.Json{
"dictionary": dictionary,
})
}
func (r *DictionaryController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
dictionary, resp := r.findDictionaryByID(ctx, id)
if resp != nil {
return resp
}
// 使用请求验证
var dictionaryUpdate adminrequests.DictionaryUpdate
errors, err := ctx.Request().ValidateRequest(&dictionaryUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["type"]; exists {
dictionary.Type = dictionaryUpdate.Type
}
if _, exists := allInputs["label"]; exists {
dictionary.Label = dictionaryUpdate.Label
}
if _, exists := allInputs["value"]; exists {
dictionary.Value = dictionaryUpdate.Value
}
if _, exists := allInputs["translation_key"]; exists {
dictionary.TranslationKey = dictionaryUpdate.TranslationKey
}
if _, exists := allInputs["description"]; exists {
dictionary.Description = dictionaryUpdate.Description
}
if _, exists := allInputs["status"]; exists {
dictionary.Status = dictionaryUpdate.Status
}
if _, exists := allInputs["sort"]; exists {
dictionary.Sort = dictionaryUpdate.Sort
}
if _, exists := allInputs["remark"]; exists {
dictionary.Remark = dictionaryUpdate.Remark
}
if err := r.dictionaryService.Update(dictionary); err != nil {
return response.ErrorWithLog(ctx, "dictionary", err, map[string]any{
"dictionary_id": dictionary.ID,
})
}
return response.Success(ctx, http.Json{
"dictionary": *dictionary,
})
}
// Destroy 删除字典
func (r *DictionaryController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
dictionary, resp := r.findDictionaryByID(ctx, id)
if resp != nil {
return resp
}
if err := r.dictionaryService.Delete(dictionary); err != nil {
return response.ErrorWithLog(ctx, "dictionary", err, map[string]any{
"dictionary_id": dictionary.ID,
})
}
return response.Success(ctx)
}
func (r *DictionaryController) GetByType(ctx http.Context) http.Response {
dictType := ctx.Request().Route("type")
if dictType == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrDictionaryTypeRequired.Code)
}
dictionaries, err := r.dictionaryService.GetByType(dictType)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"dictionaries": dictionaries,
})
}
func (r *DictionaryController) GetAllTypes(ctx http.Context) http.Response {
types, err := r.dictionaryService.GetAllTypes()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"types": types,
})
}
@@ -0,0 +1,403 @@
package admin
import (
"encoding/json"
"fmt"
nethttp "net/http"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/errorlog"
)
type ExportController struct {
exportRecordService services.ExportRecordService
}
func NewExportController() *ExportController {
return &ExportController{
exportRecordService: services.NewExportRecordService(),
}
}
// Index 导出记录列表
func (r *ExportController) Index(ctx http.Context) http.Response {
page, pageSize := helpers.ValidatePagination(
helpers.GetIntQuery(ctx, "page", 1),
helpers.GetIntQuery(ctx, "page_size", 10),
)
filters := r.buildFilters(ctx)
exports, total, err := r.exportRecordService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 为每个导出记录生成可访问的 file_url
exportService := services.NewExportService(ctx)
type ExportWithURL struct {
models.Export
FileURL string `json:"file_url"`
}
var resultWithURL []ExportWithURL
for _, e := range exports {
fileURL := ""
if e.Path != "" {
// 对于 local 和 public 存储,使用下载接口
if e.Disk == "local" || e.Disk == "public" {
fileURL = fmt.Sprintf("/api/admin/exports/%d/download", e.ID)
} else {
// 对于云存储,使用 GetExportURL 生成 URL
fileURL = exportService.GetExportURL(e.Path)
}
}
resultWithURL = append(resultWithURL, ExportWithURL{
Export: e,
FileURL: fileURL,
})
}
return response.Paginate(ctx, resultWithURL, total, page, pageSize)
}
// buildFilters 构建导出记录查询过滤器
func (r *ExportController) buildFilters(ctx http.Context) services.ExportRecordFilters {
adminID := ctx.Request().Query("admin_id", "")
exportType := ctx.Request().Query("type", "")
filename := ctx.Request().Query("filename", "")
disk := ctx.Request().Query("disk", "")
status := ctx.Request().Query("status", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.ExportRecordFilters{
AdminID: adminID,
Type: exportType,
Filename: filename,
Disk: disk,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Destroy 删除导出记录并删除源文件
func (r *ExportController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
export, err := r.exportRecordService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
// 尝试删除源文件(忽略失败,仅记录日志)
if export.Path != "" && export.Disk != "" {
storage := facades.Storage().Disk(export.Disk)
if err := storage.Delete(export.Path); err != nil {
// 删除源文件失败只记录日志,不影响主流程
errorlog.RecordHTTP(ctx, "export", "Failed to delete export source file", map[string]any{
"error": err.Error(),
"disk": export.Disk,
"path": export.Path,
}, "Delete export source file error: %v", err)
}
}
if err := r.exportRecordService.Delete(id); err != nil {
return response.ErrorWithLog(ctx, "export", err, map[string]any{
"exportId": id,
})
}
return response.Success(ctx)
}
// Download 下载导出文件
func (r *ExportController) Download(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
export, err := r.exportRecordService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if export.Path == "" || export.Disk == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilePathRequired.Code)
}
// 获取存储驱动
storage := facades.Storage().Disk(export.Disk)
// 读取文件内容
content, err := storage.Get(export.Path)
if err != nil {
return response.ErrorWithLog(ctx, "export", err, map[string]any{
"disk": export.Disk,
"path": export.Path,
})
}
// 设置响应头
filename := export.Filename
if filename == "" {
filename = export.Path
}
// 根据文件扩展名设置 Content-Type
contentType := "application/octet-stream"
if export.Extension == "csv" {
contentType = "text/csv; charset=utf-8"
} else if export.Extension == "xlsx" || export.Extension == "xls" {
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
// 设置响应头,使用链式调用确保顺序正确
response := ctx.Response().
Header("Content-Type", contentType).
Header("Content-Length", fmt.Sprintf("%d", len(content))).
Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)).
Header("Cache-Control", "no-cache, no-store, must-revalidate").
Header("Pragma", "no-cache").
Header("Expires", "0")
return response.String(http.StatusOK, content)
}
type ExportBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除导出记录并删除源文件
func (r *ExportController) BatchDestroy(ctx http.Context) http.Response {
var req ExportBatchDestroyRequest
// 使用结构体绑定
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 查询要删除的导出记录(用于删除源文件)
exports, err := r.exportRecordService.GetByIDs(ids)
if err == nil {
// 尝试删除源文件(忽略失败,仅记录日志)
for _, export := range exports {
if export.Path != "" && export.Disk != "" {
storage := facades.Storage().Disk(export.Disk)
if err := storage.Delete(export.Path); err != nil {
errorlog.RecordHTTP(ctx, "export", "Failed to delete export source file in batch delete", map[string]any{
"error": err.Error(),
"disk": export.Disk,
"path": export.Path,
}, "Delete export source file in batch delete error: %v", err)
}
}
}
}
// 批量删除数据库记录
if err := r.exportRecordService.BatchDelete(ids); err != nil {
return response.ErrorWithLog(ctx, "export", err, map[string]any{
"ids": ids,
})
}
return response.Success(ctx)
}
// StreamExportProgress SSE 实时推送导出任务进度
// 监控导出任务的状态变化,实时推送进度信息
func (r *ExportController) StreamExportProgress(ctx http.Context) http.Response {
// 获取参数
exportID := helpers.GetUintRoute(ctx, "id")
if exportID == 0 {
// 尝试从查询参数获取
exportID = helpers.GetUintQuery(ctx, "id", 0)
if exportID == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
}
// 获取推送间隔(毫秒),默认 1 秒
interval := 1000
if intervalStr := ctx.Request().Query("interval", ""); intervalStr != "" {
if parsed, err := time.ParseDuration(intervalStr + "ms"); err == nil {
interval = max(int(parsed.Milliseconds()), 500)
if interval > 5000 {
interval = 5000
}
}
}
// 设置 SSE 响应头
writer := ctx.Response().Writer()
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
writer.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲
// 发送初始连接消息
initMsg := map[string]any{
"type": "connected",
"message": "SSE连接已建立,开始监控导出任务进度",
"export_id": exportID,
"interval": interval,
}
initData, _ := json.Marshal(initMsg)
fmt.Fprintf(writer, "data: %s\n\n", string(initData))
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
// 创建 ticker,定期检查导出任务状态
ticker := time.NewTicker(time.Duration(interval) * time.Millisecond)
defer ticker.Stop()
// 检测客户端断开连接
clientGone := ctx.Request().Origin().Context().Done()
// 记录上次的状态,避免重复推送
lastStatus := uint8(255) // 使用一个不可能的值作为初始值
lastPath := ""
exportService := services.NewExportService(ctx)
for {
select {
case <-clientGone:
// 客户端断开连接
return nil
case <-ticker.C:
// 查询导出任务
export, err := r.exportRecordService.GetByID(exportID)
if err != nil {
// 导出任务不存在或已删除
errorMsg := map[string]any{
"type": "error",
"message": "导出任务不存在或已删除",
"error": err.Error(),
}
errorData, _ := json.Marshal(errorMsg)
fmt.Fprintf(writer, "data: %s\n\n", string(errorData))
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
// 继续监控,可能任务还在创建中
continue
}
// 检查状态是否有变化
if export.Status == lastStatus && export.Path == lastPath {
// 状态和路径都没有变化,跳过本次推送
// 但如果已完成,可以继续推送完成状态
if export.Status == 1 && lastStatus == 1 {
continue
}
}
// 更新记录
lastStatus = export.Status
lastPath = export.Path
// 构造进度消息
message := map[string]any{
"type": "progress",
"export_id": export.ID,
"status": export.Status,
"timestamp": time.Now().Format(time.RFC3339),
}
// 根据状态设置不同的消息
switch export.Status {
case 1:
// 导出成功
message["type"] = "completed"
message["message"] = "导出任务已完成"
message["status_text"] = "成功"
// 生成下载链接
fileURL := ""
if export.Path != "" {
if export.Disk == "local" || export.Disk == "public" {
fileURL = fmt.Sprintf("/api/admin/exports/%d/download", export.ID)
} else {
fileURL = exportService.GetExportURL(export.Path)
}
}
message["file_url"] = fileURL
message["filename"] = export.Filename
message["size"] = export.Size
case 0:
// 导出失败
message["type"] = "failed"
message["message"] = "导出任务失败"
message["status_text"] = "失败"
default:
// 处理中(Status 可能是其他值,或者我们不知道的状态)
message["message"] = "导出任务处理中"
message["status_text"] = "处理中"
}
// 如果文件路径已存在,说明正在生成文件
if export.Path != "" && export.Status != 1 {
message["message"] = "正在生成导出文件"
// 可以尝试检查文件大小来判断进度(如果存储驱动支持)
if export.Disk != "" {
storage := facades.Storage().Disk(export.Disk)
if size, err := storage.Size(export.Path); err == nil {
message["file_size"] = size
if export.Size > 0 {
progress := float64(size) / float64(export.Size) * 100
if progress > 100 {
progress = 100
}
message["progress"] = progress
}
}
}
}
messageData, err := json.Marshal(message)
if err != nil {
errorlog.RecordHTTP(ctx, "export", "Failed to marshal progress", map[string]any{
"error": err.Error(),
}, "Marshal progress error: %v", err)
continue
}
// 发送 SSE 消息
fmt.Fprintf(writer, "data: %s\n\n", string(messageData))
// 刷新缓冲区
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
// 如果已完成或失败,可以选择继续推送一段时间后关闭,或者保持连接
// 这里选择继续推送,让前端决定何时关闭
}
}
}
@@ -0,0 +1,155 @@
package admin
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/constants"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type LoginLogController struct {
loginLogService services.LoginLogService
}
func NewLoginLogController() *LoginLogController {
return &LoginLogController{
loginLogService: services.NewLoginLogService(),
}
}
// findLoginLogByID 根据ID查找登录日志,如果不存在则返回错误响应
// withAdmin 为 true 时会预加载 Admin 关联
func (r *LoginLogController) findLoginLogByID(ctx http.Context, id uint, withAdmin bool) (*models.LoginLog, http.Response) {
log, err := r.loginLogService.GetByID(id, withAdmin)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrLogNotFound.Code)
}
return log, nil
}
// buildFilters 构建查询过滤器
func (r *LoginLogController) buildFilters(ctx http.Context) services.LoginLogFilters {
adminID := ctx.Request().Query("admin_id", "")
username := ctx.Request().Query("username", "")
ip := ctx.Request().Query("ip", "")
status := ctx.Request().Query("status", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.LoginLogFilters{
AdminID: adminID,
Username: username,
IP: ip,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 获取登录日志列表
func (r *LoginLogController) Index(ctx http.Context) http.Response {
filters := r.buildFilters(ctx)
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
logs, total, err := r.loginLogService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "login-log", err)
}
return response.Success(ctx, http.Json{
"list": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 获取登录日志详情
func (r *LoginLogController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findLoginLogByID(ctx, id, true) // 预加载 Admin 关联
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"log": *log,
})
}
// Destroy 删除登录日志
func (r *LoginLogController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findLoginLogByID(ctx, id, false)
if resp != nil {
return resp
}
if _, err := facades.Orm().Query().Delete(log); err != nil {
return response.ErrorWithLog(ctx, "login-log", err, map[string]any{
"log_id": log.ID,
})
}
return response.Success(ctx)
}
type LoginLogBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除登录日志
func (r *LoginLogController) BatchDestroy(ctx http.Context) http.Response {
var req LoginLogBatchDestroyRequest
// 使用结构体绑定
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 使用工具函数转换为 []any
idsAny := helpers.ConvertUintSliceToAny(ids)
if _, err := facades.Orm().Query().WhereIn("id", idsAny).Delete(&models.LoginLog{}); err != nil {
return response.ErrorWithLog(ctx, "login-log", err, map[string]any{
"ids": ids,
})
}
return response.Success(ctx)
}
// Clean 清理登录日志
// 删除指定天数之前的日志,默认删除30天前的日志
func (r *LoginLogController) Clean(ctx http.Context) http.Response {
days := helpers.GetIntQuery(ctx, "days", constants.DefaultCleanLogDays)
if days <= 0 {
days = constants.DefaultCleanLogDays
}
cutoffTime := time.Now().AddDate(0, 0, -days)
if _, err := facades.Orm().Query().Model(&models.LoginLog{}).Where("created_at < ?", cutoffTime).Delete(&models.LoginLog{}); err != nil {
return response.ErrorWithLog(ctx, "login-log", err, map[string]any{
"days": days,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,298 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type MenuController struct {
treeService services.TreeService
menuService services.MenuService
}
func NewMenuController() *MenuController {
return &MenuController{
treeService: services.NewTreeServiceImpl(),
menuService: services.NewMenuService(),
}
}
// findMenuByID 根据ID查找菜单,如果不存在则返回错误响应
func (r *MenuController) findMenuByID(ctx http.Context, id uint) (*models.Menu, http.Response) {
return response.FindByID[models.Menu](ctx, id, &response.FindByIDOptions{
NotFoundMessageKey: apperrors.ErrMenuNotFound.Code,
})
}
// Index 菜单列表(树形结构)
func (r *MenuController) Index(ctx http.Context) http.Response {
menus, err := r.treeService.BuildMenuTree(0)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
// 检查是否需要隐藏服务监控菜单
// 只有当配置值不为空且不等于 "0" 时才隐藏("0" 表示不隐藏)
monitorHidden := facades.Config().GetString("admin.monitor_hidden", "")
if monitorHidden != "" && monitorHidden != "0" {
// 获取当前管理员ID
adminValue := ctx.Value("admin")
var adminID uint
if adminValue != nil {
if admin, ok := adminValue.(models.Admin); ok {
adminID = admin.ID
} else if adminPtr, ok := adminValue.(*models.Admin); ok && adminPtr != nil {
adminID = adminPtr.ID
}
}
// 检查是否是开发者管理员
developerIDsStr := facades.Config().GetString("admin.developer_ids", "2")
isDeveloperAdmin := r.isDeveloperAdmin(adminID, developerIDsStr)
// 如果不是开发者管理员,则过滤掉服务监控菜单
if !isDeveloperAdmin {
menus = r.filterMonitorMenu(menus)
}
}
// 检查是否需要隐藏开发工具菜单
enableDevTool := facades.Config().GetBool("app.enable_dev_tool")
if !enableDevTool {
menus = r.filterDevMenu(menus)
}
return response.Success(ctx, http.Json{
"menus": menus,
})
}
// Show 菜单详情
func (r *MenuController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
menu, resp := r.findMenuByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"menu": *menu,
})
}
// Store 创建菜单
func (r *MenuController) Store(ctx http.Context) http.Response {
// 使用请求验证
var menuCreate adminrequests.MenuCreate
errors, err := ctx.Request().ValidateRequest(&menuCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 检查 slug 是否已存在
exists, err := facades.Orm().Query().Model(&models.Menu{}).Where("slug", menuCreate.Slug).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrMenuSlugExists.Code)
}
menu, err := r.menuService.Create(
menuCreate.ParentID,
menuCreate.Title,
menuCreate.Slug,
menuCreate.Icon,
menuCreate.Path,
menuCreate.Component,
menuCreate.Permission,
menuCreate.Type,
menuCreate.Status,
menuCreate.Sort,
menuCreate.IsHidden,
menuCreate.LinkType,
menuCreate.OpenType,
)
if err != nil {
return response.ErrorWithLog(ctx, "menu", err, map[string]any{
"title": menuCreate.Title,
"slug": menuCreate.Slug,
})
}
return response.Success(ctx, http.Json{
"menu": *menu,
})
}
func (r *MenuController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
menu, resp := r.findMenuByID(ctx, id)
if resp != nil {
return resp
}
// 使用请求验证
var menuUpdate adminrequests.MenuUpdate
errors, err := ctx.Request().ValidateRequest(&menuUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["title"]; exists {
menu.Title = menuUpdate.Title
}
if _, exists := allInputs["slug"]; exists {
// 检查 slug 是否已被其他菜单使用
exists, err := facades.Orm().Query().Model(&models.Menu{}).Where("slug", menuUpdate.Slug).Where("id != ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrMenuSlugExists.Code)
}
menu.Slug = menuUpdate.Slug
}
// 处理 parent_id,需要根据是否存在来更新,如果存在但为空或0,则设为0
if val, exists := allInputs["parent_id"]; exists {
if val == nil {
menu.ParentID = 0
} else {
menu.ParentID = menuUpdate.ParentID
}
}
if _, exists := allInputs["icon"]; exists {
menu.Icon = menuUpdate.Icon
}
if _, exists := allInputs["path"]; exists {
menu.Path = menuUpdate.Path
}
if _, exists := allInputs["component"]; exists {
menu.Component = menuUpdate.Component
}
if _, exists := allInputs["permission"]; exists {
menu.Permission = menuUpdate.Permission
}
if _, exists := allInputs["type"]; exists {
menu.Type = menuUpdate.Type
}
if _, exists := allInputs["status"]; exists {
menu.Status = menuUpdate.Status
}
if _, exists := allInputs["sort"]; exists {
menu.Sort = menuUpdate.Sort
}
if _, exists := allInputs["is_hidden"]; exists {
menu.IsHidden = menuUpdate.IsHidden
}
if _, exists := allInputs["link_type"]; exists {
menu.LinkType = menuUpdate.LinkType
}
if _, exists := allInputs["open_type"]; exists {
menu.OpenType = menuUpdate.OpenType
}
if err := r.menuService.Update(menu); err != nil {
return response.ErrorWithLog(ctx, "menu", err, map[string]any{
"menu_id": menu.ID,
})
}
return response.Success(ctx, http.Json{
"menu": *menu,
})
}
func (r *MenuController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
menu, resp := r.findMenuByID(ctx, id)
if resp != nil {
return resp
}
hasChildren, err := r.treeService.HasMenuChildren(id)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
if hasChildren {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrMenuHasChildren.Code)
}
if err := r.menuService.Delete(menu); err != nil {
return response.ErrorWithLog(ctx, "menu", err, map[string]any{
"menu_id": menu.ID,
})
}
return response.Success(ctx)
}
// isDeveloperAdmin 检查是否是开发者管理员
func (r *MenuController) isDeveloperAdmin(adminID uint, developerIDsStr string) bool {
if developerIDsStr == "" {
return false
}
// 解析开发者ID列表
parts := str.Of(developerIDsStr).Split(",")
for _, part := range parts {
part = str.Of(part).Trim().String()
if !str.Of(part).IsEmpty() {
if id := cast.ToUint(part); id > 0 && id == adminID {
return true
}
}
}
return false
}
// filterMonitorMenu 递归过滤掉服务监控菜单
func (r *MenuController) filterMonitorMenu(menus []models.Menu) []models.Menu {
var filteredMenus []models.Menu
for _, menu := range menus {
// 如果当前菜单不是服务监控菜单,则保留
if menu.Slug != "monitor" {
// 递归过滤子菜单
if len(menu.Children) > 0 {
menu.Children = r.filterMonitorMenu(menu.Children)
}
filteredMenus = append(filteredMenus, menu)
}
}
return filteredMenus
}
// filterDevMenu 递归过滤掉开发工具菜单
func (r *MenuController) filterDevMenu(menus []models.Menu) []models.Menu {
var filteredMenus []models.Menu
for _, menu := range menus {
// 如果当前菜单不是开发工具菜单,则保留
if menu.Slug != "dev" {
// 递归过滤子菜单
if len(menu.Children) > 0 {
menu.Children = r.filterDevMenu(menu.Children)
}
filteredMenus = append(filteredMenus, menu)
}
}
return filteredMenus
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/logger"
)
type NotificationController struct {
service services.NotificationService
}
func NewNotificationController() *NotificationController {
return &NotificationController{
service: services.NewNotificationServiceImpl(),
}
}
func (r *NotificationController) Index(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
notifType := ctx.Request().Query("type", "")
isRead := ctx.Request().Query("is_read", "")
notifications, total, err := r.service.List(admin.ID, page, pageSize, notifType, isRead)
if err != nil {
logger.ErrorfHTTP(ctx, "list notifications error: %v", err)
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
count, err := r.service.UnreadCount(admin.ID)
if err != nil {
logger.ErrorfHTTP(ctx, "unread count error: %v", err)
}
return response.Success(ctx, http.Json{
"notifications": notifications,
"unread_count": count,
"pagination": http.Json{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": (total + int64(pageSize) - 1) / int64(pageSize),
},
})
}
func (r *NotificationController) UnreadCount(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
count, err := r.service.UnreadCount(admin.ID)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"count": count,
})
}
func (r *NotificationController) Recent(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
limit := helpers.GetIntQuery(ctx, "limit", 5)
notifications, err := r.service.ListRecent(admin.ID, limit)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
count, _ := r.service.UnreadCount(admin.ID)
return response.Success(ctx, http.Json{
"notifications": notifications,
"unread_count": count,
})
}
func (r *NotificationController) MarkRead(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsRequired.Code)
}
if err := r.service.MarkRead(admin.ID, id); err != nil {
// 使用业务错误类型,直接提取错误码
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusNotFound, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
return response.Success(ctx, http.Json{
"id": id,
})
}
func (r *NotificationController) MarkAllRead(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
if err := r.service.MarkAllRead(admin.ID); err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
return response.Success(ctx)
}
func (r *NotificationController) Store(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
title := ctx.Request().Input("title")
content := ctx.Request().Input("content")
notificationType := ctx.Request().Input("type", "announcement")
if title == "" || content == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsRequired.Code)
}
var receiverID *uint
receiverVal := ctx.Request().Input("receiver_id")
if receiverVal != "" {
id := cast.ToUint(receiverVal)
if id > 0 {
receiverID = &id
}
}
senderID := admin.ID
notification, err := r.service.Create(title, content, notificationType, &senderID, receiverID)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if notification == nil {
return response.Success(ctx)
}
return response.Success(ctx, http.Json{
"notification": notification,
})
}
func (r *NotificationController) currentAdmin(ctx http.Context) *models.Admin {
if adminValue := ctx.Value("admin"); adminValue != nil {
if admin, ok := adminValue.(models.Admin); ok {
return &admin
}
if adminPtr, ok := adminValue.(*models.Admin); ok {
return adminPtr
}
}
return nil
}
@@ -0,0 +1,83 @@
package admin
import (
"net/http"
apphttp "github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"github.com/gorilla/websocket"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/logger"
wsnotifications "goravel/app/websocket/notifications"
)
type NotificationWsController struct {
tokenService services.TokenService
}
func NewNotificationWsController() *NotificationWsController {
return &NotificationWsController{
tokenService: services.NewTokenServiceImpl(),
}
}
func (r *NotificationWsController) Server(ctx apphttp.Context) apphttp.Response {
// 记录 WebSocket 连接尝试(仅 Debug 模式)
logger.DebugfHTTP(ctx, "WebSocket connection attempt from %s, path: %s, upgrade: %s, connection: %s",
ctx.Request().Ip(),
ctx.Request().Path(),
ctx.Request().Header("Upgrade", ""),
ctx.Request().Header("Connection", ""))
token := ctx.Request().Query("token")
if token == "" {
logger.WarnfHTTP(ctx, "WebSocket connection rejected: token required")
_ = ctx.Response().Json(http.StatusUnauthorized, apphttp.Json{
"code": http.StatusUnauthorized,
"message": "token_required",
}).Abort()
return nil
}
token = str.Of(token).ChopStart("Bearer ").Trim().String()
accessToken, err := r.tokenService.FindToken(token)
if err != nil || accessToken == nil || accessToken.TokenableType != "admin" {
_ = ctx.Response().Json(http.StatusUnauthorized, apphttp.Json{
"code": http.StatusUnauthorized,
"message": "invalid_token",
}).Abort()
return nil
}
var admin models.Admin
if err := facades.Orm().Query().Where("id", accessToken.TokenableID).FirstOrFail(&admin); err != nil {
_ = ctx.Response().Json(http.StatusUnauthorized, apphttp.Json{
"code": http.StatusUnauthorized,
"message": "user_not_found",
}).Abort()
return nil
}
_ = r.tokenService.UpdateLastUsedAt(token)
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
ReadBufferSize: 1024, // 读缓冲区大小
WriteBufferSize: 1024, // 写缓冲区大小
}
conn, err := upgrader.Upgrade(ctx.Response().Writer(), ctx.Request().Origin(), nil)
if err != nil {
logger.ErrorfHTTP(ctx, "notification ws upgrade error: %v", err)
return ctx.Response().String(http.StatusInternalServerError, "upgrade_failed")
}
// logger.InfofHTTP(ctx, "WebSocket connection established for admin ID: %d", admin.ID)
wsnotifications.Hub().RegisterConnection(conn, admin.ID)
return nil
}
@@ -0,0 +1,215 @@
package admin
import (
"strings"
"time"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"github.com/spf13/cast"
"goravel/app/constants"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
)
type OnlineAdminController struct {
}
func NewOnlineAdminController() *OnlineAdminController {
return &OnlineAdminController{}
}
// Index 获取在线管理员列表
// buildQuery 构建在线管理员查询(基于 token)
func (r *OnlineAdminController) buildQuery(ctx http.Context) orm.Query {
ip := ctx.Request().Query("ip", "")
browser := ctx.Request().Query("browser", "")
os := ctx.Request().Query("os", "")
// 只查询最近15分钟内有活动的token(在线管理员)
// 默认只显示admin类型的token
onlineThreshold := time.Now().Add(-constants.OnlineAdminThreshold)
query := facades.Orm().Query().Model(&models.PersonalAccessToken{}).
Where("tokenable_type", "admin").
Where("last_used_at IS NOT NULL").
Where("last_used_at >= ?", onlineThreshold)
// 搜索条件
if ip != "" {
query = query.Where("ip LIKE ?", "%"+ip+"%")
}
if browser != "" {
query = query.Where("browser LIKE ?", "%"+browser+"%")
}
if os != "" {
query = query.Where("os LIKE ?", "%"+os+"%")
}
orderBy := ctx.Request().Query("order_by", "")
// 应用排序,默认排序为 last_used_at desc
query = helpers.ApplySort(query, orderBy, "last_used_at:desc")
return query
}
// 只显示最近15分钟内有活动的管理员(根据 OnlineAdminThreshold 常量判断)
func (r *OnlineAdminController) Index(ctx http.Context) http.Response {
// 验证并规范化分页参数
page, pageSize := helpers.ValidatePagination(
helpers.GetIntQuery(ctx, "page", 1),
helpers.GetIntQuery(ctx, "page_size", 10),
)
username := ctx.Request().Query("username", "")
query := r.buildQuery(ctx)
var tokens []models.PersonalAccessToken
if err := query.Get(&tokens); err != nil {
return response.ErrorWithLog(ctx, "online_admin", err)
}
// 批量查询所有 admin 信息,避免 N+1 查询
var adminIDs []uint
adminIDMap := make(map[uint]bool) // 用于去重
for _, token := range tokens {
if !adminIDMap[token.TokenableID] {
adminIDs = append(adminIDs, token.TokenableID)
adminIDMap[token.TokenableID] = true
}
}
// 批量查询 admin(排除开发者ID)
adminMap := make(map[uint]models.Admin)
if len(adminIDs) > 0 {
// 获取开发者ID列表并过滤
developerIDsStr := facades.Config().GetString("admin.developer_ids", "2")
developerIDs := r.parseProtectedIDs(developerIDsStr)
query := facades.Orm().Query().Where("id IN ?", adminIDs)
if len(developerIDs) > 0 {
query = query.Where("id NOT IN ?", developerIDs)
}
var admins []models.Admin
if err := query.Find(&admins); err != nil {
return response.ErrorWithLog(ctx, "online_admin", err, map[string]any{
"admin_ids": adminIDs,
})
}
// 构建 admin map
for _, admin := range admins {
adminMap[admin.ID] = admin
}
}
// 组装数据,同时过滤 username
var onlineAdmins []http.Json
for _, token := range tokens {
admin, ok := adminMap[token.TokenableID]
if !ok {
continue
}
// 如果指定了username搜索条件,进行过滤
if username != "" && !strings.Contains(strings.ToLower(admin.Username), strings.ToLower(username)) {
continue
}
onlineAdmin := http.Json{
"id": token.ID,
"admin_id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"browser": token.Browser,
"ip": token.IP,
"os": token.OS,
"session_id": token.SessionID,
"last_active": token.LastUsedAt,
"created_at": token.CreatedAt,
}
onlineAdmins = append(onlineAdmins, onlineAdmin)
}
// 使用工具函数进行分页
paginatedAdmins, total := helpers.PaginateSlice(onlineAdmins, page, pageSize)
return response.Paginate(ctx, paginatedAdmins, total, page, pageSize)
}
// KickOut 踢下线(删除token
func (r *OnlineAdminController) KickOut(ctx http.Context) http.Response {
tokenID := helpers.GetUintRoute(ctx, "id")
if tokenID == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrTokenIDRequired.Code)
}
// 查询token是否存在
var token models.PersonalAccessToken
if err := facades.Orm().Query().Where("id", tokenID).FirstOrFail(&token); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrTokenNotFound.Code)
}
// 删除token
if _, err := facades.Orm().Query().Delete(&token); err != nil {
return response.ErrorWithLog(ctx, "online_admin", err, map[string]any{
"token_id": tokenID,
})
}
return response.Success(ctx, "kick_out_success")
}
// BatchKickOut 批量踢下线
func (r *OnlineAdminController) BatchKickOut(ctx http.Context) http.Response {
tokenIDs := ctx.Request().Input("token_ids")
if tokenIDs == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrTokenIDsRequired.Code)
}
// 使用工具函数解析 token IDs
ids := helpers.ParseIDsFromString(tokenIDs)
if len(ids) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTokenIDs.Code)
}
// 批量删除token
idsAny := helpers.ConvertUintSliceToAny(ids)
if _, err := facades.Orm().Query().WhereIn("id", idsAny).Delete(&models.PersonalAccessToken{}); err != nil {
return response.ErrorWithLog(ctx, "online_admin", err, map[string]any{
"token_ids": ids,
})
}
return response.Success(ctx, "batch_kick_out_success", http.Json{
"count": len(ids),
})
}
// parseProtectedIDs 解析受保护的管理员ID字符串(支持逗号分隔)
func (r *OnlineAdminController) parseProtectedIDs(idsStr string) []uint {
var ids []uint
if idsStr == "" {
return ids
}
// 使用字符串分割
parts := str.Of(idsStr).Split(",")
for _, part := range parts {
part = str.Of(part).Trim().String()
if !str.Of(part).IsEmpty() {
if id := cast.ToUint(part); id > 0 {
ids = append(ids, id)
}
}
}
return ids
}
@@ -0,0 +1,239 @@
package admin
import (
"fmt"
"sort"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/samber/lo"
"goravel/app/constants"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type OperationLogController struct {
operationLogService services.OperationLogService
}
func NewOperationLogController() *OperationLogController {
return &OperationLogController{
operationLogService: services.NewOperationLogService(),
}
}
// findOperationLogByID 根据ID查找操作日志,如果不存在则返回错误响应
// withAdmin 为 true 时会预加载 Admin 关联
func (r *OperationLogController) findOperationLogByID(ctx http.Context, id uint, withAdmin bool) (*models.OperationLog, http.Response) {
log, err := r.operationLogService.GetByID(id, withAdmin)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrLogNotFound.Code)
}
return log, nil
}
// buildFilters 构建查询过滤器
func (r *OperationLogController) buildFilters(ctx http.Context) services.OperationLogFilters {
adminID := ctx.Request().Query("admin_id", "")
username := ctx.Request().Query("username", "")
method := ctx.Request().Query("method", "")
path := ctx.Request().Query("path", "")
title := ctx.Request().Query("title", "")
ip := ctx.Request().Query("ip", "")
status := ctx.Request().Query("status", "")
request := ctx.Request().Query("request", "")
startTimeStr := helpers.GetTimeQueryParam(ctx, "start_time")
endTimeStr := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.OperationLogFilters{
AdminID: adminID,
Username: username,
Method: method,
Path: path,
Title: title,
IP: ip,
Status: status,
Request: request,
StartTime: startTimeStr,
EndTime: endTimeStr,
OrderBy: orderBy,
}
}
// Index 获取操作日志列表
func (r *OperationLogController) Index(ctx http.Context) http.Response {
// 验证时间范围(操作日志查询限制为3个月,可通过配置修改)
startTimeStr := ctx.Request().Query("start_time", "")
endTimeStr := ctx.Request().Query("end_time", "")
// 如果只填了开始时间,结束时间默认为当前时间
if startTimeStr != "" {
startTimeUTC := helpers.GetTimeQueryParam(ctx, "start_time")
if startTimeUTC != "" {
startTime, err1 := utils.ParseDateTime(startTimeUTC)
if err1 == nil {
// 如果结束时间为空,使用当前时间
var endTime time.Time
if endTimeStr != "" {
endTimeUTC := helpers.GetTimeQueryParam(ctx, "end_time")
if endTimeUTC != "" {
var err2 error
endTime, err2 = utils.ParseDateTime(endTimeUTC)
if err2 != nil {
endTime = time.Now().UTC()
}
} else {
endTime = time.Now().UTC()
}
} else {
endTime = time.Now().UTC()
}
valid, err := utils.ValidateTimeRange(startTime, endTime, 3)
if !valid {
// 如果是 TimeRangeError,使用翻译键和参数进行翻译
if timeRangeErr, ok := err.(*utils.TimeRangeError); ok {
message := trans.Get(ctx, timeRangeErr.Key)
// 如果有参数,替换占位符 {key}
if timeRangeErr.Params != nil {
for key, value := range timeRangeErr.Params {
placeholder := fmt.Sprintf("{%s}", key)
message = strings.ReplaceAll(message, placeholder, fmt.Sprintf("%v", value))
}
}
return response.Error(ctx, http.StatusBadRequest, message)
}
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
}
}
}
filters := r.buildFilters(ctx)
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
logs, total, err := r.operationLogService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "operation-log", err)
}
return response.Success(ctx, http.Json{
"list": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 获取操作日志详情
func (r *OperationLogController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findOperationLogByID(ctx, id, true) // 预加载 Admin 关联
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"log": *log,
})
}
// Destroy 删除操作日志
func (r *OperationLogController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findOperationLogByID(ctx, id, false)
if resp != nil {
return resp
}
if _, err := facades.Orm().Query().Delete(log); err != nil {
return response.ErrorWithLog(ctx, "operation-log", err, map[string]any{
"log_id": log.ID,
})
}
return response.Success(ctx)
}
type OperationLogBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除操作日志
func (r *OperationLogController) BatchDestroy(ctx http.Context) http.Response {
var req OperationLogBatchDestroyRequest
// 使用结构体绑定
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 使用工具函数转换为 []any
idsAny := helpers.ConvertUintSliceToAny(ids)
if _, err := facades.Orm().Query().WhereIn("id", idsAny).Delete(&models.OperationLog{}); err != nil {
return response.ErrorWithLog(ctx, "operation-log", err, map[string]any{
"ids": ids,
})
}
return response.Success(ctx)
}
// Clean 清理操作日志
// 删除指定天数之前的日志,默认删除30天前的日志
func (r *OperationLogController) Clean(ctx http.Context) http.Response {
days := helpers.GetIntQuery(ctx, "days", constants.DefaultCleanLogDays)
if days <= 0 {
days = constants.DefaultCleanLogDays
}
cutoffTime := time.Now().AddDate(0, 0, -days)
if _, err := facades.Orm().Query().Model(&models.OperationLog{}).Where("created_at < ?", cutoffTime).Delete(&models.OperationLog{}); err != nil {
return response.ErrorWithLog(ctx, "operation-log", err, map[string]any{
"days": days,
})
}
return response.Success(ctx)
}
// GetTitleOptions 获取所有可用的操作标题选项
func (r *OperationLogController) GetTitleOptions(ctx http.Context) http.Response {
// 从数据库查询已存在的标题(现在标题直接存权限标识 slug,如 admin.update
var dbTitles []string
_ = facades.Orm().Query().Model(&models.OperationLog{}).
Select("DISTINCT title").
Where("title IS NOT NULL AND title != ''"). // 排除空标题
Order("title ASC").
Pluck("title", &dbTitles)
// 过滤并去重标题(权限标识),忽略旧的 operation.xxx 配置
result := lo.Uniq(lo.Filter(dbTitles, func(title string, _ int) bool {
// 排除空标题、未知标题以及旧的 operation.xxx 标题
return title != "" && title != "operation.unknown" && !strings.HasPrefix(title, "operation.")
}))
sort.Strings(result)
return response.Success(ctx, http.Json{
"titles": result,
})
}
@@ -0,0 +1,102 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/services"
"goravel/app/services/option_providers"
)
type OptionController struct {
providers map[string]services.OptionProvider
dictionaryService services.DictionaryService
}
func NewOptionController() *OptionController {
// 注册所有选项提供者
// 添加新的选项类型时,只需:
// 1. 在 app/services/option_providers/ 目录下创建新的提供者文件
// 2. 实现 services.OptionProvider 接口
// 3. 在此处注册新的提供者
providers := make(map[string]services.OptionProvider)
providers["role"] = option_providers.NewRoleOptionProvider()
providers["department"] = option_providers.NewDepartmentOptionProvider()
providers["menu"] = option_providers.NewMenuOptionProvider(services.NewTreeServiceImpl())
providers["status"] = option_providers.NewStatusOptionProvider()
providers["method"] = option_providers.NewMethodOptionProvider()
providers["yes_no"] = option_providers.NewYesNoOptionProvider()
providers["admin"] = option_providers.NewAdminOptionProvider()
providers["payment_method"] = option_providers.NewPaymentMethodOptionProvider()
// 在此处添加新的选项提供者,例如:
// providers["new_type"] = option_providers.NewNewTypeOptionProvider()
return &OptionController{
providers: providers,
dictionaryService: services.NewDictionaryService(),
}
}
// Index 获取选项列表
// 通过 type 参数指定选项类型,例如: /options?type=role
func (r *OptionController) Index(ctx http.Context) http.Response {
optionType := ctx.Request().Query("type", "")
if optionType == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrOptionTypeRequired.Code)
}
// 如果 type 是 "dictionary",则需要进一步检查 dictionary_type 参数
if optionType == "dictionary" {
dictType := ctx.Request().Query("dictionary_type", "")
if dictType == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrOptionTypeRequired.Code)
}
dictionaries, err := r.dictionaryService.GetByType(dictType)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
// 转换为选项格式,并处理多语言
var options []map[string]any
for _, dict := range dictionaries {
label := dict.Label
// 如果有 translation_key,优先使用翻译键
if dict.TranslationKey != "" {
translated := trans.Get(ctx, dict.TranslationKey)
// 只有当翻译结果不同于 key 本身(表示找到了翻译)且不为空时才使用
if translated != "" && translated != dict.TranslationKey {
label = translated
}
}
options = append(options, map[string]any{
"label": label,
"value": dict.Value,
})
}
return response.Success(ctx, options)
}
provider, exists := r.providers[optionType]
if !exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidOptionType.Code)
}
data, err := provider.GetOptions(ctx)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, data)
}
// RegisterProvider 注册新的选项提供者(可选,用于动态注册)
// 如果需要在运行时动态添加提供者,可以使用此方法
func (r *OptionController) RegisterProvider(optionType string, provider services.OptionProvider) {
r.providers[optionType] = provider
}
@@ -0,0 +1,743 @@
package admin
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/queue"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/carbon"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/jobs"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type OrderController struct {
orderService services.OrderService
}
// OrderProductItem 订单商品项(用于 Swagger 文档)
type OrderProductItem struct {
ProductID uint `json:"product_id" example:"1" binding:"required"` // 商品ID
ProductName string `json:"product_name" example:"商品名称" binding:"required"` // 商品名称
Price float64 `json:"price" example:"99.99" binding:"required"` // 单价
Quantity int `json:"quantity" example:"2" binding:"required"` // 数量
}
func NewOrderController() *OrderController {
return &OrderController{
orderService: services.NewOrderService(),
}
}
// buildFilters 构建筛选条件(列表和导出共用)
// 同时支持查询参数(GET)和请求体参数(POST)
func (r *OrderController) buildFilters(ctx http.Context) (services.OrderFilters, http.Response) {
// 优先从请求体读取,如果没有则从查询参数读取(兼容 GET 和 POST)
userID := cast.ToUint(ctx.Request().Input("user_id", ctx.Request().Query("user_id", "0")))
orderNo := ctx.Request().Input("order_no", ctx.Request().Query("order_no", ""))
status := ctx.Request().Input("status", ctx.Request().Query("status", ""))
minAmount := cast.ToFloat64(ctx.Request().Input("min_amount", ctx.Request().Query("min_amount", "0")))
maxAmount := cast.ToFloat64(ctx.Request().Input("max_amount", ctx.Request().Query("max_amount", "0")))
orderBy := ctx.Request().Input("order_by", ctx.Request().Query("order_by", ""))
// 解析时间参数(使用 GetTimeQueryParam 处理时区转换)
// GetTimeQueryParam 会自动从查询参数读取并转换为 UTC 时间字符串
// 如果查询参数不存在,尝试从请求体读取
startTimeStr := ctx.Request().Query("start_time", "")
if startTimeStr == "" {
startTimeStr = ctx.Request().Input("start_time", "")
}
endTimeStr := ctx.Request().Query("end_time", "")
if endTimeStr == "" {
endTimeStr = ctx.Request().Input("end_time", "")
}
startTime, endTime, err := r.parseTimeRange(ctx, startTimeStr, endTimeStr)
if err != nil {
return services.OrderFilters{}, response.Error(ctx, http.StatusBadRequest, err.Error())
}
// 验证时间范围(订单查询限制为3个月,可通过配置修改)
valid, err := utils.ValidateTimeRange(startTime, endTime, 3)
if !valid {
// 如果是 TimeRangeError,使用翻译键和参数进行翻译
if timeRangeErr, ok := err.(*utils.TimeRangeError); ok {
message := trans.Get(ctx, timeRangeErr.Key)
// 如果有参数,替换占位符 {key}
if timeRangeErr.Params != nil {
for key, value := range timeRangeErr.Params {
placeholder := fmt.Sprintf("{%s}", key)
message = strings.ReplaceAll(message, placeholder, fmt.Sprintf("%v", value))
}
}
return services.OrderFilters{}, response.Error(ctx, http.StatusBadRequest, message)
}
return services.OrderFilters{}, response.Error(ctx, http.StatusBadRequest, err.Error())
}
return services.OrderFilters{
UserID: userID,
OrderNo: orderNo,
Status: status,
MinAmount: minAmount,
MaxAmount: maxAmount,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}, nil
}
// parseTimeRange 解析时间范围(默认最近1周)
func (r *OrderController) parseTimeRange(ctx http.Context, startTimeStr, endTimeStr string) (time.Time, time.Time, error) {
var startTime, endTime time.Time
var err error
if startTimeStr == "" {
// 默认查询最近1周(UTC 时间)
startTime = time.Now().UTC().AddDate(0, 0, -7)
} else {
// 使用 ConvertTimeToUTC 处理时区转换(将本地时区转换为 UTC)
utcTimeStr := helpers.ConvertTimeToUTC(ctx, startTimeStr)
if utcTimeStr == "" {
return time.Time{}, time.Time{}, fmt.Errorf("invalid_start_time")
}
// 解析 UTC 时间字符串
startTime, err = utils.ParseDateTime(utcTimeStr)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid_start_time")
}
}
if endTimeStr == "" {
// 不传结束时间则不限制,返回零值(WHERE 条件中会跳过)
endTime = time.Time{}
} else {
// 使用 ConvertTimeToUTC 处理时区转换(将本地时区转换为 UTC)
utcTimeStr := helpers.ConvertTimeToUTC(ctx, endTimeStr)
if utcTimeStr == "" {
return time.Time{}, time.Time{}, fmt.Errorf("invalid_end_time")
}
// 解析 UTC 时间字符串
endTime, err = utils.ParseDateTime(utcTimeStr)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid_end_time")
}
}
return startTime, endTime, nil
}
// formatOrderStatus 格式化订单状态文本
func (r *OrderController) formatOrderStatus(ctx http.Context, status string) string {
switch status {
case "pending":
return trans.Get(ctx, "export_order_status_pending")
case "paid":
return trans.Get(ctx, "export_order_status_paid")
case "cancelled":
return trans.Get(ctx, "export_order_status_cancelled")
default:
return status
}
}
// formatTime 格式化时间为字符串(支持 time.Time 和 carbon.DateTime
func (r *OrderController) formatTime(t any) string {
if t == nil {
return ""
}
switch v := t.(type) {
case time.Time:
return utils.FormatDateTime(v)
case *time.Time:
return utils.FormatDateTimePtr(v)
case carbon.DateTime:
if v.IsZero() {
return ""
}
return v.ToDateTimeString()
case *carbon.DateTime:
if v == nil || v.IsZero() {
return ""
}
return v.ToDateTimeString()
default:
// 尝试转换为字符串(其他类型)
if str := fmt.Sprintf("%v", t); str != "" && str != "<nil>" {
return str
}
return ""
}
}
// convertOrderToJson 转换订单为响应格式
func (r *OrderController) convertOrderToJson(order models.Order) http.Json {
return http.Json{
"id": order.ID,
"order_no": order.OrderNo,
"user_id": order.UserID,
"amount": order.Amount,
"status": order.Status,
"remark": order.Remark,
"created_at": order.CreatedAt,
"updated_at": order.UpdatedAt,
}
}
// Index 订单列表
// @Summary 获取订单列表
// @Description 分页获取订单列表,支持多条件筛选,查询时间范围不能超过3个月
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param user_id query int false "用户ID"
// @Param order_no query string false "订单号(模糊搜索)"
// @Param status query string false "订单状态(pending/paid/cancelled"
// @Param min_amount query float64 false "最小金额"
// @Param max_amount query float64 false "最大金额"
// @Param start_time query string false "开始时间(格式:2006-01-02 15:04:05"
// @Param end_time query string false "结束时间(格式:2006-01-02 15:04:05"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders [get]
// @Security BearerAuth
func (r *OrderController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
// 构建筛选条件(列表和导出共用)
filters, resp := r.buildFilters(ctx)
if resp != nil {
return resp
}
// 查询订单(包含详情)
ordersWithDetails, total, err := r.orderService.GetOrdersWithDetails(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "order", err, map[string]any{
"filters": filters,
})
}
// 转换响应数据
orderList := make([]http.Json, len(ordersWithDetails))
for i, orderWithDetails := range ordersWithDetails {
orderJson := r.convertOrderToJson(orderWithDetails.Order)
// 添加订单详情
detailsList := make([]http.Json, len(orderWithDetails.Details))
for j, detail := range orderWithDetails.Details {
detailsList[j] = http.Json{
"id": detail.ID,
"order_id": detail.OrderID,
"product_id": detail.ProductID,
"product_name": detail.ProductName,
"price": detail.Price,
"quantity": detail.Quantity,
"subtotal": detail.Subtotal,
"created_at": detail.CreatedAt,
"updated_at": detail.UpdatedAt,
}
}
orderJson["details"] = detailsList
orderList[i] = orderJson
}
return response.Success(ctx, http.Json{
"data": orderList,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 订单详情
// @Summary 获取订单详情
// @Description 根据订单号获取订单详细信息,返回订单主表数据和订单详情表数据(支持分表查询)
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param id path string true "订单号"
// @Success 200 {object} map[string]any "返回数据包含 order(订单主表)和 details(订单详情表数组)"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 404 {object} map[string]any "订单不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/{id} [get]
// @Security BearerAuth
func (r *OrderController) Show(ctx http.Context) http.Response {
// 使用订单号查询(可直接定位分表)
orderNo := ctx.Request().Route("id")
if orderNo == "" {
return response.Error(ctx, http.StatusBadRequest, "order_no_required")
}
order, details, err := r.orderService.GetOrderByOrderNo(orderNo)
if err != nil {
return response.Error(ctx, http.StatusNotFound, "order_not_found")
}
return r.buildOrderDetailResponse(ctx, order, details)
}
// buildOrderDetailResponse 构建订单详情响应(提取公共逻辑)
func (r *OrderController) buildOrderDetailResponse(ctx http.Context, order *models.Order, details []models.OrderDetail) http.Response {
// 转换订单主表数据(使用统一的方法)
orderJson := r.convertOrderToJson(*order)
// 转换订单详情数据
detailList := make([]http.Json, len(details))
for i, detail := range details {
detailList[i] = http.Json{
"id": detail.ID,
"order_id": detail.OrderID,
"product_id": detail.ProductID,
"product_name": detail.ProductName,
"price": detail.Price,
"quantity": detail.Quantity,
"subtotal": detail.Subtotal,
"created_at": detail.CreatedAt,
"updated_at": detail.UpdatedAt,
}
}
// 返回主表和详情表数据
return response.Success(ctx, http.Json{
"order": orderJson,
"details": detailList,
})
}
// Store 创建订单
// @Summary 创建订单
// @Description 创建新订单,自动防止重复提交
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param user_id body int true "用户ID"
// @Param amount body float64 true "订单金额"
// @Param products body []OrderProductItem true "商品列表"
// @Param request_id body string false "请求ID(用于防重复提交,不传则自动生成)"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误或重复提交"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders [post]
// @Security BearerAuth
func (r *OrderController) Store(ctx http.Context) http.Response {
var req struct {
UserID uint `json:"user_id" binding:"required"`
Amount float64 `json:"amount" binding:"required"`
Products []services.OrderProduct `json:"products" binding:"required"`
RequestID string `json:"request_id"`
Remark string `json:"remark"`
}
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_params")
}
if len(req.Products) == 0 {
return response.Error(ctx, http.StatusBadRequest, "empty_products")
}
// 创建订单
order, details, err := r.orderService.CreateOrder(req.UserID, req.Amount, req.Products, req.RequestID, req.Remark)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, "create_failed")
}
// 转换订单详情
detailList := make([]http.Json, len(details))
for i, detail := range details {
detailList[i] = http.Json{
"id": detail.ID,
"order_id": detail.OrderID,
"product_id": detail.ProductID,
"product_name": detail.ProductName,
"price": detail.Price,
"quantity": detail.Quantity,
"subtotal": detail.Subtotal,
}
}
orderJson := r.convertOrderToJson(*order)
// 移除不需要的字段(创建订单时不需要返回 created_at 和 updated_at
delete(orderJson, "created_at")
delete(orderJson, "updated_at")
return response.Success(ctx, http.Json{
"order": orderJson,
"details": detailList,
})
}
// Update 更新订单
// @Summary 更新订单
// @Description 更新订单信息(主要是状态)。使用订单号查询(可直接定位分表)
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param id path string true "订单号"
// @Param status body string true "订单状态"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/{id} [put]
// @Security BearerAuth
func (r *OrderController) Update(ctx http.Context) http.Response {
// 使用订单号查询(可直接定位分表)
orderNo := ctx.Request().Route("id")
if orderNo == "" {
return response.Error(ctx, http.StatusBadRequest, "order_no_required")
}
var req struct {
Status string `json:"status" binding:"required"`
Remark string `json:"remark"`
}
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_params")
}
if err := r.orderService.UpdateOrderByOrderNo(orderNo, req.Status, req.Remark); err != nil {
return response.ErrorWithLog(ctx, "order", err, map[string]any{
"order_no": orderNo,
"status": req.Status,
"remark": req.Remark,
})
}
return response.Success(ctx)
}
// Destroy 删除订单
// @Summary 删除订单
// @Description 删除订单及其详情。使用订单号查询(可直接定位分表)
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param id path string true "订单号"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/{id} [delete]
// @Security BearerAuth
func (r *OrderController) Destroy(ctx http.Context) http.Response {
// 使用订单号查询(可直接定位分表)
orderNo := ctx.Request().Route("id")
if orderNo == "" {
return response.Error(ctx, http.StatusBadRequest, "order_no_required")
}
if err := r.orderService.DeleteOrderByOrderNo(orderNo); err != nil {
return response.ErrorWithLog(ctx, "order", err, map[string]any{
"order_no": orderNo,
})
}
return response.Success(ctx)
}
// Export 导出订单列表
// @Summary 导出订单列表
// @Description 根据筛选条件导出订单列表为CSV文件,支持与列表查询相同的筛选条件,查询时间范围不能超过3个月
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param user_id query int false "用户ID"
// @Param order_no query string false "订单号(模糊搜索)"
// @Param status query string false "订单状态(pending/paid/cancelled"
// @Param min_amount query float64 false "最小金额"
// @Param max_amount query float64 false "最大金额"
// @Param start_time query string false "开始时间(格式:2006-01-02 15:04:05"
// @Param end_time query string false "结束时间(格式:2006-01-02 15:04:05"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} map[string]any "导出成功,返回文件下载信息"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/export [post]
// @Security BearerAuth
func (r *OrderController) Export(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 防重复点击:使用框架自带的原子锁(锁会在10秒后自动过期,防止短时间内重复请求)
lockKey := fmt.Sprintf("export:orders:lock:%d", adminID)
lock := facades.Cache().Lock(lockKey, 10*time.Second)
// 尝试获取锁,如果获取失败则返回错误
if !lock.Get() {
return response.Error(ctx, http.StatusTooManyRequests, "export_in_progress")
}
// 构建筛选条件
filters, resp := r.buildFilters(ctx)
if resp != nil {
return resp
}
// 创建导出记录(状态为处理中)
// 获取存储驱动配置
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = utils.GetConfigValue("storage", "export_disk", "")
}
if disk == "" {
disk = "local"
}
exportRecord := models.Export{
AdminID: adminID,
Type: models.ExportTypeOrders,
Status: models.ExportStatusProcessing,
Disk: disk,
Path: "", // 处理完成后更新
}
if err := facades.Orm().Query().Create(&exportRecord); err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 将筛选条件序列化为 JSON
filtersMap := map[string]any{
"user_id": filters.UserID,
"order_no": filters.OrderNo,
"status": filters.Status,
"min_amount": filters.MinAmount,
"max_amount": filters.MaxAmount,
"order_by": filters.OrderBy,
}
if !filters.StartTime.IsZero() {
filtersMap["start_time"] = utils.FormatDateTime(filters.StartTime)
}
if !filters.EndTime.IsZero() {
filtersMap["end_time"] = utils.FormatDateTime(filters.EndTime)
}
// 获取当前语言(从请求头或查询参数,与 middleware 逻辑一致)
lang := r.getCurrentLanguage(ctx)
timezone := helpers.GetCurrentTimezone(ctx)
// 异步执行导出任务(使用 Job
// 将参数序列化为 JSON 字符串传递,避免框架对复杂类型的序列化问题
exportArgsStruct := jobs.ExportOrdersArgs{
ExportID: exportRecord.ID,
AdminID: adminID,
Filters: filtersMap,
Type: "orders",
Language: lang,
Timezone: timezone,
}
// 序列化为 JSON 字符串
exportArgsJSON, err := json.Marshal(exportArgsStruct)
if err != nil {
facades.Log().Errorf("序列化导出参数失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
// 记录任务提交日志
facades.Log().Infof("提交导出任务到队列: export_id=%d, queue_driver=%s, args_json=%s",
exportRecord.ID, facades.Config().GetString("queue.default"), string(exportArgsJSON))
// 使用 queue.Arg 包装 JSON 字符串参数
exportArgs := []queue.Arg{
{
Type: "string",
Value: string(exportArgsJSON),
},
}
// 传递 JSON 字符串作为参数,使用 long-running 队列,避免长时间运行的导出任务影响其他队列任务
// 所有耗时任务(导出、报表生成、批量处理等)都应该使用 long-running 队列
if err := facades.Queue().Job(&jobs.ExportOrders{}, exportArgs).OnQueue("long-running").Dispatch(); err != nil {
// 如果任务提交失败,立即释放锁,让用户可以立即重试
lock.Release()
facades.Log().Errorf("提交导出任务失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
facades.Log().Infof("导出任务已成功提交到队列: export_id=%d", exportRecord.ID)
return response.Success(ctx, http.Json{
"export_id": exportRecord.ID,
"message": trans.Get(ctx, "export_task_submitted"),
})
}
// GetExportStatus 查询导出状态
// @Summary 查询导出状态
// @Description 根据导出记录ID查询导出任务的状态
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param id path int true "导出记录ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/export/status/{id} [get]
// @Security BearerAuth
func (r *OrderController) GetExportStatus(ctx http.Context) http.Response {
exportID := helpers.GetUintRoute(ctx, "id")
if exportID == 0 {
return response.Error(ctx, http.StatusBadRequest, "export_id_required")
}
exportRecordService := services.NewExportRecordService()
exportRecord, err := exportRecordService.GetByID(exportID)
if err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 检查权限:只能查看自己的导出记录
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
if exportRecord.AdminID != adminID {
return response.Error(ctx, http.StatusForbidden, "forbidden")
}
// 生成文件URL
fileURL := ""
if exportRecord.Path != "" && exportRecord.Status == models.ExportStatusSuccess {
exportService := services.NewExportService(ctx)
if exportRecord.Disk == "local" || exportRecord.Disk == "public" {
fileURL = fmt.Sprintf("/api/admin/exports/%d/download", exportRecord.ID)
} else {
fileURL = exportService.GetExportURL(exportRecord.Path)
}
}
return response.Success(ctx, http.Json{
"id": exportRecord.ID,
"status": exportRecord.Status,
"status_text": r.getExportStatusText(ctx, exportRecord.Status),
"file_url": fileURL,
"filename": exportRecord.Filename,
"size": exportRecord.Size,
"error_msg": exportRecord.ErrorMsg,
"created_at": exportRecord.CreatedAt.ToDateTimeString(),
"updated_at": exportRecord.UpdatedAt.ToDateTimeString(),
})
}
func (r *OrderController) getExportStatusText(ctx http.Context, status uint8) string {
switch status {
case models.ExportStatusProcessing:
return trans.Get(ctx, "export_task_status_processing")
case models.ExportStatusSuccess:
return trans.Get(ctx, "export_task_status_success")
case models.ExportStatusFailed:
return trans.Get(ctx, "export_task_status_failed")
default:
return trans.Get(ctx, "export_task_status_unknown")
}
}
// getCurrentLanguage 获取当前请求的语言(使用通用工具函数)
func (r *OrderController) getCurrentLanguage(ctx http.Context) string {
return utils.GetCurrentLanguage(ctx)
}
// Import 导入订单
// @Summary 导入订单
// @Description 从CSV文件导入订单数据,支持批量导入
// @Tags 订单管理
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "CSV文件"
// @Success 200 {object} map[string]any "导入成功,返回导入结果"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/import [post]
// @Security BearerAuth
func (r *OrderController) Import(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 获取上传的文件
file, err := ctx.Request().File("file")
if err != nil {
return response.Error(ctx, http.StatusBadRequest, "file_required")
}
// 验证文件类型(只允许CSV
filename := file.GetClientOriginalName()
if !strings.HasSuffix(strings.ToLower(filename), ".csv") {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidFileType.Code)
}
// 读取文件内容
storage := facades.Storage().Disk("local")
savedPath, err := storage.PutFile("", file)
if err != nil {
return response.ErrorWithLog(ctx, "import", err, map[string]any{
"filename": filename,
})
}
// 读取文件内容
csvContent, err := storage.Get(savedPath)
if err != nil {
_ = storage.Delete(savedPath)
return response.ErrorWithLog(ctx, "import", err, map[string]any{
"filename": filename,
})
}
// 清理临时文件
defer func() {
_ = storage.Delete(savedPath)
}()
// 导入订单
importService := services.NewImportOrderService(ctx)
result, err := importService.ImportOrders(csvContent)
if err != nil {
return response.ErrorWithLog(ctx, "import", err, map[string]any{
"filename": filename,
"admin_id": adminID,
})
}
return response.Success(ctx, http.Json{
"total_rows": result.TotalRows,
"success_count": result.SuccessCount,
"failed_count": result.FailedCount,
"errors": result.Errors,
"message": trans.Get(ctx, "import_success"),
})
}
@@ -0,0 +1,102 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
)
type PasswordController struct {
}
func NewPasswordController() *PasswordController {
return &PasswordController{}
}
func (r *PasswordController) UpdatePassword(ctx http.Context) http.Response {
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
if err := facades.Orm().Query().Where("id", admin.ID).FirstOrFail(&admin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
// 使用请求验证
var updatePasswordRequest adminrequests.UpdatePassword
errors, err := ctx.Request().ValidateRequest(&updatePasswordRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 验证旧密码是否正确
if !facades.Hash().Check(updatePasswordRequest.OldPassword, admin.Password) {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrOldPasswordError.Code)
}
hashedPassword, err := facades.Hash().Make(updatePasswordRequest.NewPassword)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrPasswordEncryptFailed.Code)
}
admin.Password = hashedPassword
if err := facades.Orm().Query().Save(&admin); err != nil {
return response.ErrorWithLog(ctx, "password", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "password_update_success")
}
// ResetPassword 重置密码(管理员操作)
func (r *PasswordController) ResetPassword(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 使用请求验证
var resetPasswordRequest adminrequests.ResetPassword
errors, err := ctx.Request().ValidateRequest(&resetPasswordRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
var admin models.Admin
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&admin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
hashedPassword, err := facades.Hash().Make(resetPasswordRequest.Password)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrPasswordEncryptFailed.Code)
}
admin.Password = hashedPassword
if err := facades.Orm().Query().Save(&admin); err != nil {
return response.ErrorWithLog(ctx, "password", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "password_reset_success")
}
@@ -0,0 +1,421 @@
package admin
import (
"encoding/json"
"fmt"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/queue"
"github.com/goravel/framework/facades"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/jobs"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type PaymentController struct {
paymentService services.PaymentService
}
func NewPaymentController() *PaymentController {
return &PaymentController{
paymentService: services.NewPaymentService(),
}
}
// buildFilters 构建筛选条件(列表和导出共用)
func (r *PaymentController) buildFilters(ctx http.Context) (services.PaymentFilters, http.Response) {
paymentNo := ctx.Request().Input("payment_no", ctx.Request().Query("payment_no", ""))
orderNo := ctx.Request().Input("order_no", ctx.Request().Query("order_no", ""))
paymentMethodID := cast.ToUint(ctx.Request().Input("payment_method_id", ctx.Request().Query("payment_method_id", "0")))
userID := cast.ToUint(ctx.Request().Input("user_id", ctx.Request().Query("user_id", "0")))
status := ctx.Request().Input("status", ctx.Request().Query("status", ""))
orderBy := ctx.Request().Input("order_by", ctx.Request().Query("order_by", ""))
// 解析时间参数
startTimeStr := ctx.Request().Query("start_time", "")
if startTimeStr == "" {
startTimeStr = ctx.Request().Input("start_time", "")
}
endTimeStr := ctx.Request().Query("end_time", "")
if endTimeStr == "" {
endTimeStr = ctx.Request().Input("end_time", "")
}
var startTime, endTime time.Time
var err error
if startTimeStr != "" {
utcTimeStr := helpers.ConvertTimeToUTC(ctx, startTimeStr)
if utcTimeStr == "" {
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, "invalid_start_time")
}
startTime, err = utils.ParseDateTime(utcTimeStr)
if err != nil {
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, "invalid_start_time")
}
}
if endTimeStr != "" {
utcTimeStr := helpers.ConvertTimeToUTC(ctx, endTimeStr)
if utcTimeStr == "" {
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, "invalid_end_time")
}
endTime, err = utils.ParseDateTime(utcTimeStr)
if err != nil {
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, "invalid_end_time")
}
}
// 与列表保持一致:未传 start_time 时默认最近 7 天;未传 end_time 时默认当前时间
// 这样导出数据集与列表查询数据集一致,并避免扫到未建表的历史月份
if startTime.IsZero() {
startTime = time.Now().UTC().AddDate(0, 0, -7)
}
if endTime.IsZero() {
endTime = time.Now().UTC()
}
// 校验时间范围不超过 3 个月(与列表/导出一致)
if valid, err := utils.ValidateTimeRange(startTime, endTime); !valid {
// ValidateTimeRange 返回的是可翻译错误键,这里直接返回 key 交给前端处理
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, err.Error())
}
return services.PaymentFilters{
PaymentNo: paymentNo,
OrderNo: orderNo,
PaymentMethodID: paymentMethodID,
UserID: userID,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}, nil
}
// Index 支付记录列表
// @Summary 获取支付记录列表
// @Description 分页获取支付记录列表,支持多条件筛选
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param payment_no query string false "支付单号(模糊搜索)"
// @Param order_no query string false "订单号(模糊搜索)"
// @Param payment_method_id query int false "支付方式ID"
// @Param user_id query int false "用户ID"
// @Param status query string false "支付状态(pending/paid/failed/cancelled"
// @Param start_time query string false "开始时间(格式:2006-01-02 15:04:05"
// @Param end_time query string false "结束时间(格式:2006-01-02 15:04:05"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payments [get]
// @Security BearerAuth
func (r *PaymentController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters, resp := r.buildFilters(ctx)
if resp != nil {
return resp
}
payments, total, err := r.paymentService.GetPayments(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "payment", err, map[string]any{
"filters": filters,
})
}
// 转换响应数据
paymentList := make([]http.Json, len(payments))
for i, payment := range payments {
paymentJson := http.Json{
"id": payment.ID,
"payment_no": payment.PaymentNo,
"order_no": payment.OrderNo,
"payment_method_id": payment.PaymentMethodID,
"user_id": payment.UserID,
"amount": payment.Amount,
"status": payment.Status,
"third_party_no": payment.ThirdPartyNo,
"pay_time": r.formatPayTime(payment.PayTime),
"fail_reason": payment.FailReason,
"remark": payment.Remark,
"created_at": payment.CreatedAt,
"updated_at": payment.UpdatedAt,
}
// 添加支付方式信息
if payment.PaymentMethod.ID > 0 {
paymentJson["payment_method"] = http.Json{
"id": payment.PaymentMethod.ID,
"name": payment.PaymentMethod.Name,
"code": payment.PaymentMethod.Code,
"type": payment.PaymentMethod.Type,
}
}
paymentList[i] = paymentJson
}
return response.Success(ctx, http.Json{
"data": paymentList,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 支付记录详情
// @Summary 获取支付记录详情
// @Description 根据支付单号获取支付记录详细信息(分表后ID可能重复,使用支付单号查询)
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path string true "支付单号"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 404 {object} map[string]any "支付记录不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payments/{id} [get]
// @Security BearerAuth
func (r *PaymentController) Show(ctx http.Context) http.Response {
paymentNo := ctx.Request().Route("id") // 路由参数名保持兼容
if paymentNo == "" {
return response.Error(ctx, http.StatusBadRequest, "payment_no_required")
}
payment, err := r.paymentService.GetPaymentByPaymentNo(paymentNo)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrPaymentNotFound.Code)
}
paymentJson := http.Json{
"id": payment.ID,
"payment_no": payment.PaymentNo,
"order_no": payment.OrderNo,
"payment_method_id": payment.PaymentMethodID,
"user_id": payment.UserID,
"amount": payment.Amount,
"status": payment.Status,
"third_party_no": payment.ThirdPartyNo,
"pay_time": r.formatPayTime(payment.PayTime),
"fail_reason": payment.FailReason,
"remark": payment.Remark,
"created_at": payment.CreatedAt,
"updated_at": payment.UpdatedAt,
}
// 添加支付方式信息
if payment.PaymentMethod.ID > 0 {
paymentJson["payment_method"] = http.Json{
"id": payment.PaymentMethod.ID,
"name": payment.PaymentMethod.Name,
"code": payment.PaymentMethod.Code,
"type": payment.PaymentMethod.Type,
}
}
return response.Success(ctx, paymentJson)
}
// formatPayTime 格式化支付时间为字符串
func (r *PaymentController) formatPayTime(t *time.Time) string {
return utils.FormatDateTimePtr(t)
}
// Export 导出支付记录
// @Summary 导出支付记录
// @Description 异步导出支付记录为CSV文件
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param payment_no query string false "支付单号"
// @Param order_no query string false "订单号"
// @Param payment_method_id query int false "支付方式ID"
// @Param user_id query int false "用户ID"
// @Param status query string false "支付状态"
// @Param start_time query string false "开始时间"
// @Param end_time query string false "结束时间"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 429 {object} map[string]any "导出任务正在进行中"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payments/export [post]
// @Security BearerAuth
func (r *PaymentController) Export(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 防重复点击
lockKey := fmt.Sprintf("export:payments:lock:%d", adminID)
lock := facades.Cache().Lock(lockKey, 10*time.Second)
if !lock.Get() {
return response.Error(ctx, http.StatusTooManyRequests, "export_in_progress")
}
// 构建筛选条件
filters, resp := r.buildFilters(ctx)
if resp != nil {
return resp
}
// 获取存储驱动配置
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = utils.GetConfigValue("storage", "export_disk", "")
}
if disk == "" {
disk = "local"
}
exportRecord := models.Export{
AdminID: adminID,
Type: models.ExportTypePayments,
Status: models.ExportStatusProcessing,
Disk: disk,
Path: "",
}
if err := facades.Orm().Query().Create(&exportRecord); err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 序列化筛选条件
filtersMap := map[string]any{
"payment_no": filters.PaymentNo,
"order_no": filters.OrderNo,
"payment_method_id": filters.PaymentMethodID,
"user_id": filters.UserID,
"status": filters.Status,
"order_by": filters.OrderBy,
}
if !filters.StartTime.IsZero() {
filtersMap["start_time"] = utils.FormatDateTime(filters.StartTime)
}
if !filters.EndTime.IsZero() {
filtersMap["end_time"] = utils.FormatDateTime(filters.EndTime)
}
lang := r.getCurrentLanguage(ctx)
timezone := helpers.GetCurrentTimezone(ctx)
exportArgsStruct := jobs.ExportPaymentsArgs{
ExportID: exportRecord.ID,
AdminID: adminID,
Filters: filtersMap,
Type: "payments",
Language: lang,
Timezone: timezone,
}
exportArgsJSON, err := json.Marshal(exportArgsStruct)
if err != nil {
facades.Log().Errorf("序列化导出参数失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
facades.Log().Infof("提交支付记录导出任务到队列: export_id=%d", exportRecord.ID)
exportArgs := []queue.Arg{
{
Type: "string",
Value: string(exportArgsJSON),
},
}
if err := facades.Queue().Job(&jobs.ExportPayments{}, exportArgs).OnQueue("long-running").Dispatch(); err != nil {
lock.Release()
facades.Log().Errorf("提交导出任务失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
facades.Log().Infof("支付记录导出任务已成功提交到队列: export_id=%d", exportRecord.ID)
return response.Success(ctx, http.Json{
"export_id": exportRecord.ID,
"message": trans.Get(ctx, "export_task_submitted"),
})
}
// GetExportStatus 查询导出状态
// @Summary 查询支付记录导出状态
// @Description 根据导出记录ID查询导出任务的状态
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path int true "导出记录ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 404 {object} map[string]any "导出记录不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payments/export/status/{id} [get]
// @Security BearerAuth
func (r *PaymentController) GetExportStatus(ctx http.Context) http.Response {
exportID := helpers.GetUintRoute(ctx, "id")
if exportID == 0 {
return response.Error(ctx, http.StatusBadRequest, "export_id_required")
}
var exportRecord models.Export
if err := facades.Orm().Query().Where("id", exportID).FirstOrFail(&exportRecord); err != nil {
return response.Error(ctx, http.StatusNotFound, "export_not_found")
}
result := http.Json{
"id": exportRecord.ID,
"status": exportRecord.Status,
"status_text": r.getExportStatusText(ctx, exportRecord.Status),
"path": exportRecord.Path,
"filename": exportRecord.Filename,
"size": exportRecord.Size,
"error_msg": exportRecord.ErrorMsg,
"created_at": exportRecord.CreatedAt,
"updated_at": exportRecord.UpdatedAt,
}
if exportRecord.Status == models.ExportStatusSuccess && exportRecord.Path != "" {
result["download_url"] = fmt.Sprintf("/api/admin/exports/%d/download", exportRecord.ID)
}
return response.Success(ctx, result)
}
// getCurrentLanguage 获取当前语言(使用通用工具函数)
func (r *PaymentController) getCurrentLanguage(ctx http.Context) string {
return utils.GetCurrentLanguage(ctx)
}
// getExportStatusText 获取导出状态文本
func (r *PaymentController) getExportStatusText(ctx http.Context, status uint8) string {
switch status {
case models.ExportStatusProcessing:
return trans.Get(ctx, "export_task_status_processing")
case models.ExportStatusSuccess:
return trans.Get(ctx, "export_task_status_success")
case models.ExportStatusFailed:
return trans.Get(ctx, "export_task_status_failed")
default:
return trans.Get(ctx, "export_task_status_unknown")
}
}
@@ -0,0 +1,289 @@
package admin
import (
"encoding/json"
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/services"
)
type PaymentMethodController struct {
paymentService services.PaymentService
}
func NewPaymentMethodController() *PaymentMethodController {
return &PaymentMethodController{
paymentService: services.NewPaymentService(),
}
}
// Index 支付方式列表
// @Summary 获取支付方式列表
// @Description 分页获取支付方式列表,支持多条件筛选
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param name query string false "支付方式名称(模糊搜索)"
// @Param code query string false "支付方式代码"
// @Param type query string false "支付类型"
// @Param is_active query string false "是否启用:1-启用,0-禁用"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods [get]
// @Security BearerAuth
func (r *PaymentMethodController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := services.PaymentMethodFilters{
Name: ctx.Request().Query("name", ""),
Code: ctx.Request().Query("code", ""),
Type: ctx.Request().Query("type", ""),
IsActive: ctx.Request().Query("is_active", ""),
Description: ctx.Request().Query("description", ""),
OrderBy: ctx.Request().Query("order_by", ""),
}
paymentMethods, total, err := r.paymentService.GetPaymentMethods(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "payment_method", err, map[string]any{
"filters": filters,
})
}
// 转换响应数据(不返回敏感配置信息)
paymentMethodList := make([]http.Json, len(paymentMethods))
for i, pm := range paymentMethods {
paymentMethodList[i] = http.Json{
"id": pm.ID,
"name": pm.Name,
"code": pm.Code,
"type": pm.Type,
"is_active": pm.IsActive,
"sort": pm.Sort,
"description": pm.Description,
"created_at": pm.CreatedAt,
"updated_at": pm.UpdatedAt,
}
}
return response.Success(ctx, http.Json{
"data": paymentMethodList,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 支付方式详情
// @Summary 获取支付方式详情
// @Description 根据ID获取支付方式详细信息
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path int true "支付方式ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 404 {object} map[string]any "支付方式不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods/{id} [get]
// @Security BearerAuth
func (r *PaymentMethodController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
paymentMethod, err := r.paymentService.GetPaymentMethodByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrPaymentMethodNotFound.Code)
}
// 解析配置 JSON
var config map[string]any
if paymentMethod.Config != "" {
if err := json.Unmarshal([]byte(paymentMethod.Config), &config); err != nil {
config = make(map[string]any)
}
} else {
config = make(map[string]any)
}
return response.Success(ctx, http.Json{
"id": paymentMethod.ID,
"name": paymentMethod.Name,
"code": paymentMethod.Code,
"type": paymentMethod.Type,
"config": config,
"is_active": paymentMethod.IsActive,
"sort": paymentMethod.Sort,
"description": paymentMethod.Description,
"created_at": paymentMethod.CreatedAt,
"updated_at": paymentMethod.UpdatedAt,
})
}
// Store 创建支付方式
// @Summary 创建支付方式
// @Description 创建新的支付方式
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param name body string true "支付方式名称"
// @Param code body string true "支付方式代码"
// @Param type body string true "支付类型"
// @Param config body object true "支付配置(JSON对象)"
// @Param is_active body bool false "是否启用"
// @Param sort body int false "排序"
// @Param description body string false "描述"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods [post]
// @Security BearerAuth
func (r *PaymentMethodController) Store(ctx http.Context) http.Response {
var req adminrequests.PaymentMethodCreate
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
paymentMethod, err := r.paymentService.CreatePaymentMethod(
req.Name,
req.Code,
req.Type,
req.Config,
req.IsActive,
req.Sort,
req.Description,
)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "payment_method", err, map[string]any{
"name": req.Name,
"code": req.Code,
})
}
return response.Success(ctx, http.Json{
"id": paymentMethod.ID,
"name": paymentMethod.Name,
"code": paymentMethod.Code,
"type": paymentMethod.Type,
"is_active": paymentMethod.IsActive,
"sort": paymentMethod.Sort,
"description": paymentMethod.Description,
"created_at": paymentMethod.CreatedAt,
"updated_at": paymentMethod.UpdatedAt,
})
}
// Update 更新支付方式
// @Summary 更新支付方式
// @Description 更新支付方式信息
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path int true "支付方式ID"
// @Param name body string true "支付方式名称"
// @Param config body object false "支付配置(JSON对象)"
// @Param is_active body bool false "是否启用"
// @Param sort body int false "排序"
// @Param description body string false "描述"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods/{id} [put]
// @Security BearerAuth
func (r *PaymentMethodController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 获取支付方式
paymentMethod, err := r.paymentService.GetPaymentMethodByID(id)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusNotFound, businessErr.Code)
}
return response.Error(ctx, http.StatusNotFound, apperrors.ErrPaymentMethodNotFound.Code)
}
var req adminrequests.PaymentMethodUpdate
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["name"]; exists {
paymentMethod.Name = req.Name
}
if _, exists := allInputs["config"]; exists && req.Config != nil {
configBytes, err := json.Marshal(req.Config)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPaymentConfigRequired.Code)
}
paymentMethod.Config = string(configBytes)
}
if _, exists := allInputs["is_active"]; exists {
paymentMethod.IsActive = req.IsActive
}
if _, exists := allInputs["sort"]; exists {
paymentMethod.Sort = req.Sort
}
if _, exists := allInputs["description"]; exists {
paymentMethod.Description = req.Description
}
if err := r.paymentService.UpdatePaymentMethodModel(paymentMethod); err != nil {
return response.ErrorWithLog(ctx, "payment_method", err, map[string]any{
"id": id,
})
}
return response.Success(ctx, http.Json{
"payment_method": *paymentMethod,
})
}
// Destroy 删除支付方式
// @Summary 删除支付方式
// @Description 删除支付方式
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path int true "支付方式ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods/{id} [delete]
// @Security BearerAuth
func (r *PaymentMethodController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
err := r.paymentService.DeletePaymentMethod(id)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "payment_method", err, map[string]any{
"id": id,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,224 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type PermissionController struct {
permissionService services.PermissionService
treeService services.TreeService
}
func NewPermissionController() *PermissionController {
return &PermissionController{
permissionService: services.NewPermissionService(),
treeService: services.NewTreeServiceImpl(),
}
}
// findPermissionByID 根据ID查找权限,如果不存在则返回错误响应
// withMenu 为 true 时会预加载 Menu 关联
func (r *PermissionController) findPermissionByID(ctx http.Context, id uint, withMenu bool) (*models.Permission, http.Response) {
permission, err := r.permissionService.GetByID(id, withMenu)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrPermissionNotFound.Code)
}
return permission, nil
}
// buildFilters 构建查询过滤器
func (r *PermissionController) buildFilters(ctx http.Context) services.PermissionFilters {
name := ctx.Request().Query("name", "")
slug := ctx.Request().Query("slug", "")
method := ctx.Request().Query("method", "")
path := ctx.Request().Query("path", "")
status := ctx.Request().Query("status", "")
menuID := ctx.Request().Query("menu_id", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.PermissionFilters{
Name: name,
Slug: slug,
Method: method,
Path: path,
Status: status,
MenuID: menuID,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 权限列表
func (r *PermissionController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
permissions, total, err := r.permissionService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "permission", err)
}
return response.Success(ctx, http.Json{
"list": permissions,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 权限详情
func (r *PermissionController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
permission, resp := r.findPermissionByID(ctx, id, true) // 预加载 Menu 关联
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"permission": *permission,
})
}
// Store 创建权限
func (r *PermissionController) Store(ctx http.Context) http.Response {
name := ctx.Request().Input("name")
slug := ctx.Request().Input("slug")
method := ctx.Request().Input("method")
path := ctx.Request().Input("path")
description := ctx.Request().Input("description")
status := cast.ToUint8(ctx.Request().Input("status", "0"))
sort := cast.ToInt(ctx.Request().Input("sort", "0"))
menuID := cast.ToUint(ctx.Request().Input("menu_id", "0"))
if name == "" || slug == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPermissionNameAndSlugRequired.Code)
}
exists, err := facades.Orm().Query().Model(&models.Permission{}).
Where("name", name).
OrWhere("slug", slug).
Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPermissionNameOrSlugExists.Code)
}
permission, err := r.permissionService.Create(
name,
slug,
method,
path,
description,
status,
sort,
menuID,
)
if err != nil {
return response.ErrorWithLog(ctx, "permission", err, map[string]any{
"name": name,
"slug": slug,
})
}
return response.Success(ctx, http.Json{
"permission": *permission,
})
}
func (r *PermissionController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
permission, resp := r.findPermissionByID(ctx, id, false)
if resp != nil {
return resp
}
name := ctx.Request().Input("name")
slug := ctx.Request().Input("slug")
method := ctx.Request().Input("method")
path := ctx.Request().Input("path")
description := ctx.Request().Input("description")
status := ctx.Request().Input("status", "")
sort := ctx.Request().Input("sort", "")
menuIDStr := ctx.Request().Input("menu_id", "")
if name != "" {
exists, err := facades.Orm().Query().Model(&models.Permission{}).Where("name", name).Where("id <> ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPermissionNameExists.Code)
}
permission.Name = name
}
if slug != "" {
exists, err := facades.Orm().Query().Model(&models.Permission{}).Where("slug", slug).Where("id <> ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPermissionSlugExists.Code)
}
permission.Slug = slug
}
if method != "" {
permission.Method = method
}
if path != "" {
permission.Path = path
}
if description != "" {
permission.Description = description
}
if status != "" {
permission.Status = cast.ToUint8(status)
}
if sort != "" {
permission.Sort = cast.ToInt(sort)
}
if menuIDStr != "" {
permission.MenuID = cast.ToUint(menuIDStr)
}
if err := r.permissionService.Update(permission); err != nil {
return response.ErrorWithLog(ctx, "permission", err, map[string]any{
"permission_id": permission.ID,
})
}
return response.Success(ctx, http.Json{
"permission": *permission,
})
}
// Destroy 删除权限
func (r *PermissionController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
permission, resp := r.findPermissionByID(ctx, id, false) // 不需要预加载关联
if resp != nil {
return resp
}
if err := r.permissionService.Delete(permission); err != nil {
return response.ErrorWithLog(ctx, "permission", err, map[string]any{
"permission_id": permission.ID,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,311 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type RoleController struct {
roleService services.RoleService
}
func NewRoleController() *RoleController {
return &RoleController{
roleService: services.NewRoleServiceImpl(),
}
}
// findRoleByID 根据ID查找角色,如果不存在则返回错误响应
// withRelations 为 true 时会预加载 Permissions 和 Menus 关联
func (r *RoleController) findRoleByID(ctx http.Context, id uint, withRelations bool) (*models.Role, http.Response) {
role, err := r.roleService.GetByID(id, withRelations)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrRoleNotFound.Code)
}
return role, nil
}
// buildFilters 构建查询过滤器
func (r *RoleController) buildFilters(ctx http.Context) services.RoleFilters {
name := ctx.Request().Query("name", "")
status := ctx.Request().Query("status", "")
// 使用辅助函数自动转换时区
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.RoleFilters{
Name: name,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 角色列表
func (r *RoleController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
roles, total, err := r.roleService.GetList(filters, page, pageSize)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": roles,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 角色详情
func (r *RoleController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
role, resp := r.findRoleByID(ctx, id, true) // 预加载关联
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"role": *role,
})
}
// Store 创建角色
func (r *RoleController) Store(ctx http.Context) http.Response {
// 使用请求验证
var roleCreate adminrequests.RoleCreate
errors, err := ctx.Request().ValidateRequest(&roleCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 检查名称是否已存在
exists, err := facades.Orm().Query().Model(&models.Role{}).Where("name", roleCreate.Name).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrRoleNameExists.Code)
}
// 检查标识是否已存在
exists, err = facades.Orm().Query().Model(&models.Role{}).Where("slug", roleCreate.Slug).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrRoleSlugExists.Code)
}
role, err := r.roleService.Create(
roleCreate.Name,
roleCreate.Slug,
roleCreate.Description,
roleCreate.Status,
roleCreate.Sort,
)
if err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"name": roleCreate.Name,
"slug": roleCreate.Slug,
})
}
// 处理权限关联
permissionIDs := r.roleService.ParseIDsFromRequest(ctx, "permission_ids")
if len(permissionIDs) > 0 {
if err := r.roleService.SyncPermissions(role, permissionIDs); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
"permission_ids": permissionIDs,
})
}
}
// 处理菜单关联
menuIDs := r.roleService.ParseIDsFromRequest(ctx, "menu_ids")
if len(menuIDs) > 0 {
if err := r.roleService.SyncMenus(role, menuIDs); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
"menu_ids": menuIDs,
})
}
}
return response.Success(ctx, http.Json{
"role": *role,
})
}
// parseProtectedRoleSlugs 解析受保护的角色标识字符串(支持逗号分隔)
func (r *RoleController) parseProtectedRoleSlugs(slugsStr string) []string {
var slugs []string
if slugsStr == "" {
return slugs
}
parts := str.Of(slugsStr).Split(",")
for _, part := range parts {
part = str.Of(part).Trim().String()
if !str.Of(part).IsEmpty() {
slugs = append(slugs, part)
}
}
return slugs
}
// isProtectedRole 检查角色是否是受保护的(通过slug判断)
func (r *RoleController) isProtectedRole(roleSlug string) bool {
protectedSlugsStr := facades.Config().GetString("role.protected_slugs", "super-admin")
protectedSlugs := r.parseProtectedRoleSlugs(protectedSlugsStr)
for _, protectedSlug := range protectedSlugs {
if roleSlug == protectedSlug {
return true
}
}
return false
}
// Update 更新角色
func (r *RoleController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
role, resp := r.findRoleByID(ctx, id, false)
if resp != nil {
return resp
}
// 检查是否是受保护的角色(通过slug判断)
isProtected := r.isProtectedRole(role.Slug)
// 使用请求验证
var roleUpdate adminrequests.RoleUpdate
errors, err := ctx.Request().ValidateRequest(&roleUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["name"]; exists {
// 检查名称是否已被其他角色使用(排除当前角色)
exists, err := facades.Orm().Query().Model(&models.Role{}).Where("name", roleUpdate.Name).Where("id != ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrRoleNameExists.Code)
}
role.Name = roleUpdate.Name
}
if _, exists := allInputs["slug"]; exists {
// 只有当 slug 值真正改变时才检查
if roleUpdate.Slug != role.Slug {
// 受保护角色的标识不能修改
if isProtected {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrRoleProtectedCannotModifySlug.Code)
}
// 检查标识是否已被其他角色使用(排除当前角色)
exists, err := facades.Orm().Query().Model(&models.Role{}).Where("slug", roleUpdate.Slug).Where("id != ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrRoleSlugExists.Code)
}
role.Slug = roleUpdate.Slug
}
// 如果 slug 值未改变,跳过更新(允许其他字段正常更新)
}
if _, exists := allInputs["description"]; exists {
// 描述字段允许修改,包括受保护角色(如 super-admin)也可以修改描述
role.Description = roleUpdate.Description
}
if _, exists := allInputs["status"]; exists {
// 受保护角色不能禁用
if isProtected && roleUpdate.Status == 0 {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrRoleProtectedCannotDisable.Code)
}
role.Status = roleUpdate.Status
}
if _, exists := allInputs["sort"]; exists {
role.Sort = roleUpdate.Sort
}
if err := r.roleService.Update(role); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
})
}
// super-admin 角色拥有所有权限,不需要设置菜单和权限
// 处理权限关联
if !isProtected && ctx.Request().Input("permission_ids") != "" {
permissionIDs := r.roleService.ParseIDsFromRequest(ctx, "permission_ids")
if err := r.roleService.SyncPermissions(role, permissionIDs); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
"permission_ids": permissionIDs,
})
}
}
// 处理菜单关联
if !isProtected && ctx.Request().Input("menu_ids") != "" {
menuIDs := r.roleService.ParseIDsFromRequest(ctx, "menu_ids")
if err := r.roleService.SyncMenus(role, menuIDs); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
"menu_ids": menuIDs,
})
}
}
return response.Success(ctx, http.Json{
"role": *role,
})
}
// Destroy 删除角色
func (r *RoleController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
role, resp := r.findRoleByID(ctx, id, false)
if resp != nil {
return resp
}
// 检查是否是受保护的角色(通过slug判断)
if r.isProtectedRole(role.Slug) {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrRoleProtectedCannotDelete.Code)
}
if _, err := facades.Orm().Query().Delete(role); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,154 @@
package admin
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/constants"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type SystemLogController struct {
systemLogService services.SystemLogService
}
func NewSystemLogController() *SystemLogController {
return &SystemLogController{
systemLogService: services.NewSystemLogService(),
}
}
// findSystemLogByID 根据ID查找系统日志,如果不存在则返回错误响应
func (r *SystemLogController) findSystemLogByID(ctx http.Context, id uint) (*models.SystemLog, http.Response) {
log, err := r.systemLogService.GetByID(id)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrLogNotFound.Code)
}
return log, nil
}
// buildFilters 构建查询过滤器
func (r *SystemLogController) buildFilters(ctx http.Context) services.SystemLogFilters {
level := ctx.Request().Query("level", "")
module := ctx.Request().Query("module", "")
traceID := ctx.Request().Query("trace_id", "")
message := ctx.Request().Query("message", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.SystemLogFilters{
Level: level,
Module: module,
TraceID: traceID,
Message: message,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 获取系统日志列表
func (r *SystemLogController) Index(ctx http.Context) http.Response {
filters := r.buildFilters(ctx)
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
logs, total, err := r.systemLogService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "system-log", err)
}
return response.Success(ctx, http.Json{
"list": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 获取系统日志详情
func (r *SystemLogController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findSystemLogByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"log": *log,
})
}
// Destroy 删除系统日志
func (r *SystemLogController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findSystemLogByID(ctx, id)
if resp != nil {
return resp
}
if _, err := facades.Orm().Query().Delete(log); err != nil {
return response.ErrorWithLog(ctx, "system-log", err, map[string]any{
"log_id": log.ID,
})
}
return response.Success(ctx)
}
type SystemLogBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除系统日志
func (r *SystemLogController) BatchDestroy(ctx http.Context) http.Response {
var req SystemLogBatchDestroyRequest
// 使用结构体绑定
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 使用工具函数转换为 []any
idsAny := helpers.ConvertUintSliceToAny(ids)
if _, err := facades.Orm().Query().WhereIn("id", idsAny).Delete(&models.SystemLog{}); err != nil {
return response.ErrorWithLog(ctx, "system-log", err, map[string]any{
"ids": ids,
})
}
return response.Success(ctx)
}
// Clean 清理系统日志
// 删除指定天数之前的日志,默认删除30天前的日志
func (r *SystemLogController) Clean(ctx http.Context) http.Response {
days := helpers.GetIntQuery(ctx, "days", constants.DefaultCleanLogDays)
if days <= 0 {
days = constants.DefaultCleanLogDays
}
cutoffTime := time.Now().AddDate(0, 0, -days)
if _, err := facades.Orm().Query().Model(&models.SystemLog{}).Where("created_at < ?", cutoffTime).Delete(&models.SystemLog{}); err != nil {
return response.ErrorWithLog(ctx, "system-log", err, map[string]any{
"days": days,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,170 @@
package admin
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/services"
"goravel/app/utils"
)
type UserBalanceLogController struct {
balanceLogService services.UserBalanceLogService
}
func NewUserBalanceLogController() *UserBalanceLogController {
return &UserBalanceLogController{
balanceLogService: services.NewUserBalanceLogService(),
}
}
// Index 余额变动记录列表
func (r *UserBalanceLogController) Index(ctx http.Context) http.Response {
userID := cast.ToUint(ctx.Request().Query("user_id", "0"))
if userID == 0 {
return response.Error(ctx, http.StatusBadRequest, "user_id_required_for_sharding")
}
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
// 解析时间
startTimeStr := ctx.Request().Query("start_time", "")
endTimeStr := ctx.Request().Query("end_time", "")
var startTime, endTime time.Time
if startTimeStr != "" {
startTime, _ = utils.ParseDateTime(startTimeStr)
}
if endTimeStr != "" {
endTime, _ = utils.ParseDateTime(endTimeStr)
}
var operatorID *uint
if operatorIDStr := ctx.Request().Query("operator_id", ""); operatorIDStr != "" {
id := cast.ToUint(operatorIDStr)
operatorID = &id
}
filters := services.UserBalanceLogFilters{
UserID: userID,
Type: ctx.Request().Query("type", ""),
Source: ctx.Request().Query("source", ""),
Status: ctx.Request().Query("status", ""),
StartTime: startTime,
EndTime: endTime,
OperatorID: operatorID,
}
logs, total, err := r.balanceLogService.GetLogs(filters, page, pageSize)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Statistics 用户余额统计
func (r *UserBalanceLogController) Statistics(ctx http.Context) http.Response {
userID := cast.ToUint(ctx.Request().Query("user_id", "0"))
if userID == 0 {
return response.Error(ctx, http.StatusBadRequest, "user_id_required")
}
startTimeStr := ctx.Request().Query("start_time", "")
endTimeStr := ctx.Request().Query("end_time", "")
var startTime, endTime time.Time
if startTimeStr != "" {
startTime, _ = utils.ParseDateTime(startTimeStr)
}
if endTimeStr != "" {
endTime, _ = utils.ParseDateTime(endTimeStr)
}
stats, err := r.balanceLogService.GetUserStatistics(userID, startTime, endTime)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"statistics": stats,
})
}
// Store 创建余额变动记录
func (r *UserBalanceLogController) Store(ctx http.Context) http.Response {
userID := cast.ToUint(ctx.Request().Input("user_id", "0"))
if userID == 0 {
return response.Error(ctx, http.StatusBadRequest, "user_id_required_for_sharding")
}
logType := ctx.Request().Input("type", "")
if logType == "" {
return response.Error(ctx, http.StatusBadRequest, "balance_type_required")
}
amount := cast.ToFloat64(ctx.Request().Input("amount", "0"))
if amount == 0 {
return response.Error(ctx, http.StatusBadRequest, "amount_cannot_be_zero")
}
balance := cast.ToFloat64(ctx.Request().Input("balance", "0"))
source := ctx.Request().Input("source", "manual")
description := ctx.Request().Input("description", "")
remark := ctx.Request().Input("remark", "")
status := ctx.Request().Input("status", "success")
var sourceID *uint
if sourceIDStr := ctx.Request().Input("source_id", ""); sourceIDStr != "" {
id := cast.ToUint(sourceIDStr)
sourceID = &id
}
var operatorID *uint
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err == nil && adminID > 0 {
operatorID = &adminID
}
// 如果未提供 balance,从用户表获取当前余额
if balance == 0 {
currentBalance, err := r.balanceLogService.GetUserBalance(userID)
if err != nil {
// 检查是否是业务错误
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, "get_user_balance_failed")
}
balance = currentBalance
}
log, err := r.balanceLogService.CreateLog(userID, logType, amount, balance, source, sourceID, description, operatorID, status, remark)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, "balance_log_create_success", http.Json{
"data": log,
})
}
@@ -0,0 +1,332 @@
package admin
import (
"encoding/json"
"fmt"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/queue"
"github.com/goravel/framework/facades"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/jobs"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type UserController struct {
userService services.UserService
}
func NewUserController() *UserController {
return &UserController{
userService: services.NewUserService(),
}
}
// Index 用户列表
func (r *UserController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := services.UserFilters{
Username: ctx.Request().Query("username", ""),
Email: ctx.Request().Query("email", ""),
Phone: ctx.Request().Query("phone", ""),
Status: ctx.Request().Query("status", ""),
}
users, total, err := r.userService.GetList(filters, page, pageSize)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": users,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 用户详情
func (r *UserController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
user, err := r.userService.GetByID(id)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusNotFound, businessErr.Code)
}
return response.Error(ctx, http.StatusNotFound, err.Error())
}
return response.Success(ctx, http.Json{
"user": user,
})
}
// Store 创建用户
func (r *UserController) Store(ctx http.Context) http.Response {
// 使用请求验证
var userCreate adminrequests.UserCreate
errors, err := ctx.Request().ValidateRequest(&userCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用服务方法创建用户(包含验证、密码加密、默认货币设置)
user, err := r.userService.CreateWithValidation(
userCreate.Username,
userCreate.Password,
userCreate.Nickname,
userCreate.Email,
userCreate.Phone,
userCreate.Status,
)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "user", err, map[string]any{
"username": userCreate.Username,
})
}
return response.Success(ctx, http.Json{
"user": user,
})
}
// Update 更新用户
func (r *UserController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 使用请求验证
var userUpdate adminrequests.UserUpdate
errors, err := ctx.Request().ValidateRequest(&userUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用服务方法验证用户是否存在(排除当前用户)
if err := r.userService.ValidateUserExists("", userUpdate.Email, userUpdate.Phone, id); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
user := models.User{
Nickname: userUpdate.Nickname,
Email: userUpdate.Email,
Phone: userUpdate.Phone,
Status: userUpdate.Status,
}
// 如果提供了密码,则加密
if userUpdate.Password != "" {
hashedPassword, err := facades.Hash().Make(userUpdate.Password)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, "password_encrypt_failed")
}
user.Password = hashedPassword
}
if err := r.userService.Update(id, &user); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"user": user,
})
}
// Destroy 删除用户
func (r *UserController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if err := r.userService.Delete(id); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, "delete_success", http.Json{})
}
// UpdateBalance 更新用户余额
func (r *UserController) UpdateBalance(ctx http.Context) http.Response {
// 从路由参数获取 user_id
userID := helpers.GetUintRoute(ctx, "id")
amount := cast.ToFloat64(ctx.Request().Input("amount", "0"))
logType := ctx.Request().Input("type", "")
source := ctx.Request().Input("source", "manual")
description := ctx.Request().Input("description", "")
remark := ctx.Request().Input("remark", "")
var sourceID *uint
if sourceIDStr := ctx.Request().Input("source_id", ""); sourceIDStr != "" {
id := cast.ToUint(sourceIDStr)
sourceID = &id
}
var operatorID *uint
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err == nil && adminID > 0 {
operatorID = &adminID
}
if userID == 0 {
return response.Error(ctx, http.StatusBadRequest, "user_id_required")
}
if amount == 0 {
return response.Error(ctx, http.StatusBadRequest, "amount_cannot_be_zero")
}
if logType == "" {
return response.Error(ctx, http.StatusBadRequest, "balance_type_required")
}
if err := r.userService.UpdateBalance(userID, amount, logType, source, sourceID, description, operatorID, remark); err != nil {
// response.Error 会自动检测 BusinessError 并处理占位符替换
return response.Error(ctx, http.StatusBadRequest, err)
}
return response.Success(ctx, "balance_update_success", http.Json{})
}
// ResetPassword 重置用户密码(管理员操作)
func (r *UserController) ResetPassword(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 使用请求验证
var resetPasswordRequest adminrequests.ResetPassword
errors, err := ctx.Request().ValidateRequest(&resetPasswordRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用服务方法重置密码
if err := r.userService.ResetPassword(id, resetPasswordRequest.Password); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "password", err, map[string]any{
"user_id": id,
})
}
return response.Success(ctx, "password_reset_success", http.Json{})
}
// Export 导出用户列表
func (r *UserController) Export(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 防重复点击
lockKey := fmt.Sprintf("export:users:lock:%d", adminID)
lock := facades.Cache().Lock(lockKey, 10*time.Second)
if !lock.Get() {
return response.Error(ctx, http.StatusTooManyRequests, "export_in_progress")
}
// 获取存储驱动配置
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = utils.GetConfigValue("storage", "export_disk", "")
}
if disk == "" {
disk = "local"
}
exportRecord := models.Export{
AdminID: adminID,
Type: models.ExportTypeUsers,
Status: models.ExportStatusProcessing,
Disk: disk,
Path: "",
}
if err := facades.Orm().Query().Create(&exportRecord); err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 构建筛选条件(POST 请求,从 body 获取参数)
filtersMap := map[string]any{
"username": ctx.Request().Input("username", ""),
"nickname": ctx.Request().Input("nickname", ""),
"email": ctx.Request().Input("email", ""),
"phone": ctx.Request().Input("phone", ""),
"order_by": ctx.Request().Input("order_by", "id:desc"),
}
if statusStr := ctx.Request().Input("status", ""); statusStr != "" {
filtersMap["status"] = cast.ToUint(statusStr)
}
lang := utils.GetCurrentLanguage(ctx)
timezone := helpers.GetCurrentTimezone(ctx)
exportArgsStruct := jobs.ExportUsersArgs{
ExportID: exportRecord.ID,
AdminID: adminID,
Filters: filtersMap,
Type: "users",
Language: lang,
Timezone: timezone,
}
exportArgsJSON, err := json.Marshal(exportArgsStruct)
if err != nil {
facades.Log().Errorf("序列化导出参数失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
facades.Log().Infof("提交用户导出任务到队列: export_id=%d", exportRecord.ID)
exportArgs := []queue.Arg{
{
Type: "string",
Value: string(exportArgsJSON),
},
}
if err := facades.Queue().Job(&jobs.ExportUsers{}, exportArgs).OnQueue("long-running").Dispatch(); err != nil {
lock.Release()
facades.Log().Errorf("提交导出任务失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
return response.Success(ctx, http.Json{
"export_id": exportRecord.ID,
"message": "export_task_submitted",
})
}
+187
View File
@@ -0,0 +1,187 @@
package api
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
apirequests "goravel/app/http/requests/api"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type AuthController struct {
userService services.UserService
}
func NewAuthController() *AuthController {
return &AuthController{
userService: services.NewUserService(),
}
}
// Register 用户注册
func (r *AuthController) Register(ctx http.Context) http.Response {
var registerRequest apirequests.UserRegister
errors, err := ctx.Request().ValidateRequest(&registerRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用服务方法创建用户(包含验证、密码加密、默认货币设置)
user, err := r.userService.CreateWithValidation(
registerRequest.Username,
registerRequest.Password,
registerRequest.Nickname,
registerRequest.Email,
registerRequest.Phone,
1, // C端注册默认启用
)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "user", err, map[string]any{
"username": registerRequest.Username,
})
}
// 注册成功后自动登录,使用Goravel标准Auth生成token
token, err := facades.Auth(ctx).Guard("user").Login(&user)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"user_id": user.ID,
})
}
return response.SuccessWithHeader(ctx, "register_success", "Authorization", "Bearer "+token, http.Json{
"token": token,
"user": http.Json{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"email": user.Email,
"phone": user.Phone,
"balance": user.Balance,
"status": user.Status,
},
})
}
// Login 用户登录
func (r *AuthController) Login(ctx http.Context) http.Response {
var loginRequest apirequests.UserLogin
errors, err := ctx.Request().ValidateRequest(&loginRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 验证用户名是否存在
exists, err := facades.Orm().Query().Model(&models.User{}).Where("username", loginRequest.Username).Exists()
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"username": loginRequest.Username,
})
}
if !exists {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUsernameOrPasswordErr.Code)
}
// 获取用户信息
var user models.User
if err := facades.Orm().Query().Where("username", loginRequest.Username).FirstOrFail(&user); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"username": loginRequest.Username,
})
}
if user.Status == 0 {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAccountDisabled.Code)
}
// 验证密码
if !facades.Hash().Check(loginRequest.Password, user.Password) {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUsernameOrPasswordErr.Code)
}
// 使用Goravel标准Auth生成token
token, err := facades.Auth(ctx).Guard("user").Login(&user)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"user_id": user.ID,
})
}
// 更新最后登录时间
now := time.Now()
user.LastLoginAt = &now
facades.Orm().Query().Save(&user)
return response.SuccessWithHeader(ctx, "login_success", "Authorization", "Bearer "+token, http.Json{
"token": token,
"user": http.Json{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"email": user.Email,
"phone": user.Phone,
"balance": user.Balance,
"status": user.Status,
"last_login_at": user.LastLoginAt,
},
})
}
// Info 获取当前用户信息
func (r *AuthController) Info(ctx http.Context) http.Response {
// 使用Goravel标准Auth获取用户
var user models.User
if err := facades.Auth(ctx).Guard("user").User(&user); err != nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 重新查询用户以确保获取最新数据(包括关联的货币信息)
if err := facades.Orm().Query().With("Currency").Where("id", user.ID).FirstOrFail(&user); err != nil {
return response.Error(ctx, http.StatusNotFound, "user_not_found")
}
return response.Success(ctx, http.Json{
"user": http.Json{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"email": user.Email,
"phone": user.Phone,
"balance": user.Balance,
"currency_id": user.CurrencyID,
"currency": user.Currency,
"status": user.Status,
"last_login_at": user.LastLoginAt,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
},
})
}
// Logout 用户登出
func (r *AuthController) Logout(ctx http.Context) http.Response {
// 使用Goravel标准Auth登出
if err := facades.Auth(ctx).Guard("user").Logout(); err != nil {
// 即使登出失败也返回成功,因为token可能已经过期
facades.Log().Warningf("Failed to logout: %v", err)
}
return response.Success(ctx, "logout_success", http.Json{})
}
@@ -0,0 +1,56 @@
package controllers
import (
"github.com/goravel/framework/contracts/http"
httpSwagger "github.com/swaggo/http-swagger/v2"
_ "goravel/docs"
)
/*********************************
1. Install swag
document: https://github.com/swaggo/http-swagger
go install github.com/swaggo/swag/cmd/swag@latest
2. Install http-swagger
go get -u github.com/swaggo/http-swagger
3. Optimize the document of endpoint: `app/http/controllers/swagger_controller.go`
4. Add route to `/route/web.go`
5. Init document
swag init
6. Run Server
air
7. Visit: http://localhost:3000/swagger/
********************************/
type SwaggerController struct {
// Dependent services
}
func NewSwaggerController() *SwaggerController {
return &SwaggerController{
// Inject services
}
}
// Index an example for Swagger
//
// @Summary Summary
// @Description Description
// @Tags example
// @Accept json
// @Success 200
// @Failure 400
// @Router /swagger [get]
func (r *SwaggerController) Index(ctx http.Context) http.Response {
handler := httpSwagger.Handler()
handler.ServeHTTP(ctx.Response().Writer(), ctx.Request().Origin())
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package helpers
import (
"errors"
"github.com/goravel/framework/contracts/http"
"goravel/app/models"
)
// GetAdminFromContext 从 context 中获取 admin 对象
// 如果 context 中没有 admin 或类型不匹配,返回错误
func GetAdminFromContext(ctx http.Context) (*models.Admin, error) {
adminValue := ctx.Value("admin")
if adminValue == nil {
return nil, errors.New("admin not found in context")
}
// 尝试值类型
if admin, ok := adminValue.(models.Admin); ok {
return &admin, nil
}
// 尝试指针类型
if adminPtr, ok := adminValue.(*models.Admin); ok {
return adminPtr, nil
}
return nil, errors.New("invalid admin type in context")
}
// GetAdminIDFromContext 从 context 中获取 admin ID
// 如果 context 中没有 admin 或类型不匹配,返回 0 和错误
func GetAdminIDFromContext(ctx http.Context) (uint, error) {
admin, err := GetAdminFromContext(ctx)
if err != nil {
return 0, err
}
return admin.ID, nil
}
+79
View File
@@ -0,0 +1,79 @@
package helpers
import (
"net"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support/str"
)
// GetRealIP 获取客户端真实IP地址
// 优先从以下HTTP头获取(按顺序):
// 1. CF-Connecting-IP (Cloudflare)
// 2. True-Client-IP
// 3. X-Real-IP
// 4. X-Forwarded-For (取第一个IP)
// 5. RemoteAddr
func GetRealIP(ctx http.Context) string {
// 1. Cloudflare
if ip := ctx.Request().Header("CF-Connecting-IP", ""); ip != "" {
if parsedIP := parseIP(ip); parsedIP != "" {
return parsedIP
}
}
// 2. True-Client-IP
if ip := ctx.Request().Header("True-Client-IP", ""); ip != "" {
if parsedIP := parseIP(ip); parsedIP != "" {
return parsedIP
}
}
// 3. X-Real-IP
if ip := ctx.Request().Header("X-Real-IP", ""); ip != "" {
if parsedIP := parseIP(ip); parsedIP != "" {
return parsedIP
}
}
// 4. X-Forwarded-For (可能包含多个IP,取第一个)
if forwardedFor := ctx.Request().Header("X-Forwarded-For", ""); !str.Of(forwardedFor).IsEmpty() {
ips := str.Of(forwardedFor).Split(",")
if len(ips) > 0 {
ip := str.Of(ips[0]).Trim().String()
if parsedIP := parseIP(ip); !str.Of(parsedIP).IsEmpty() {
return parsedIP
}
}
}
// 5. RemoteAddr
remoteAddr := ctx.Request().Ip()
if parsedIP := parseIP(remoteAddr); parsedIP != "" {
return parsedIP
}
return remoteAddr
}
// parseIP 解析并验证IP地址
func parseIP(ip string) string {
ip = str.Of(ip).Trim().String()
if str.Of(ip).IsEmpty() {
return ""
}
// 如果包含端口,去掉端口
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
// 验证是否为有效IP
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return ""
}
return ip
}
+6
View File
@@ -0,0 +1,6 @@
package helpers
// 注意:FindByID 函数已移动到 response 包中,以避免循环导入
// 请使用 response.FindByID 代替 helpers.FindByID
// 相关的 FindByIDOptions 类型也在 response 包中
+45
View File
@@ -0,0 +1,45 @@
package helpers
// PaginateSlice 对切片进行分页处理
// 返回分页后的切片和总数
func PaginateSlice[T any](slice []T, page, pageSize int) ([]T, int64) {
total := int64(len(slice))
if total == 0 {
return []T{}, 0
}
start := (page - 1) * pageSize
end := start + pageSize
// 如果起始位置超出范围,返回空切片
if start >= len(slice) {
return []T{}, total
}
// 如果结束位置超出范围,截取到末尾
if end > len(slice) {
end = len(slice)
}
return slice[start:end], total
}
// ValidatePagination 验证并规范化分页参数
// 返回规范化后的 page 和 pageSize
func ValidatePagination(page, pageSize int) (int, int) {
// 默认值
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 10
}
// 最大限制
const maxPageSize = 100
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return page, pageSize
}
+105
View File
@@ -0,0 +1,105 @@
package helpers
import (
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/support/str"
)
// ApplySort 应用排序到查询
// orderBy: 排序参数,格式为 "field:direction" 或 "field1:direction1,field2:direction2"
// defaultSort: 默认排序,格式为 "field:direction",如果 orderBy 为空则使用此默认值
// 返回: 应用了排序的查询对象
func ApplySort(query orm.Query, orderBy string, defaultSort string) orm.Query {
// 如果提供了排序参数,使用它;否则使用默认排序
sortStr := orderBy
if sortStr == "" {
sortStr = defaultSort
}
// 如果排序字符串为空,返回原查询
if sortStr == "" {
return query
}
// 解析多个排序字段(逗号分隔)
sortFields := str.Of(sortStr).Split(",")
var orderClauses []string
for _, field := range sortFields {
field = str.Of(field).Trim().String()
if str.Of(field).IsEmpty() {
continue
}
// 解析字段和方向(格式: "field:direction" 或 "field"
parts := str.Of(field).Split(":")
fieldName := str.Of(parts[0]).Trim().String()
direction := "asc" // 默认升序
if len(parts) > 1 {
direction = str.Of(parts[1]).Trim().Lower().String()
}
// 验证方向
if direction != "asc" && direction != "desc" {
direction = "asc"
}
// 收集排序子句
orderClauses = append(orderClauses, fieldName+" "+direction)
}
// 如果有排序子句,组合成一个字符串并应用
if len(orderClauses) > 0 {
var orderStr string
if len(orderClauses) > 0 {
orderStr = orderClauses[0]
for i := 1; i < len(orderClauses); i++ {
orderStr = str.Of(orderStr).Append(", ").Append(orderClauses[i]).String()
}
}
query = query.Order(orderStr)
}
return query
}
// ParseSort 解析排序参数
// orderBy: 排序参数,格式为 "field:direction" 或 "field1:direction1,field2:direction2"
// 返回: 排序字段和方向的映射
func ParseSort(orderBy string) map[string]string {
result := make(map[string]string)
if orderBy == "" {
return result
}
// 解析多个排序字段(逗号分隔)
sortFields := str.Of(orderBy).Split(",")
for _, field := range sortFields {
field = str.Of(field).Trim().String()
if str.Of(field).IsEmpty() {
continue
}
// 解析字段和方向
parts := str.Of(field).Split(":")
fieldName := str.Of(parts[0]).Trim().String()
direction := "asc" // 默认升序
if len(parts) > 1 {
direction = str.Of(parts[1]).Trim().Lower().String()
}
// 验证方向
if direction != "asc" && direction != "desc" {
direction = "asc"
}
result[fieldName] = direction
}
return result
}
+282
View File
@@ -0,0 +1,282 @@
package helpers
import (
"encoding/json"
"goravel/app/utils"
"reflect"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support/carbon"
)
// ConvertTimesInData 递归转换数据中的时间字段到对应时区
// 使用 JSON 序列化和反序列化来确保正确处理所有类型
func ConvertTimesInData(ctx http.Context, data any) any {
if data == nil {
return nil
}
// 获取请求的时区
timezone := GetCurrentTimezone(ctx)
// 检查是否传了时区请求头
hasTimezoneHeader := ctx.Request().Header("X-Timezone", "") != "" ||
ctx.Request().Header("Timezone", "") != "" ||
ctx.Request().Input("timezone") != ""
// 如果没有传时区请求头,且时区是 UTC,直接返回原数据(不做转换)
if !hasTimezoneHeader && (timezone == carbon.UTC || timezone == "UTC") {
return data
}
// 先序列化为 JSON
jsonData, err := json.Marshal(data)
if err != nil {
// 如果序列化失败,尝试使用反射方法
return convertTimesInValue(reflect.ValueOf(data), timezone)
}
// 反序列化为 map[string]any
var result any
if err := json.Unmarshal(jsonData, &result); err != nil {
// 如果反序列化失败,返回原数据
return data
}
// 转换时间字段
converted := convertTimesInMap(result, timezone)
return converted
}
// convertTimesInMap 递归处理 map 或 slice 中的时间字段
func convertTimesInMap(data any, timezone string) any {
if data == nil {
return nil
}
switch v := data.(type) {
case map[string]any:
result := make(map[string]any)
for key, value := range v {
// 检查是否是时间字段
if isTimeField(key) {
// 尝试解析时间字符串并转换
if timeStr, ok := value.(string); ok && timeStr != "" {
// 如果时区是 UTC,直接返回原时间字符串(不做转换)
if timezone == carbon.UTC || timezone == "UTC" {
result[key] = timeStr
continue
}
// 否则进行时区转换
converted := convertTimeString(timeStr, timezone)
if converted != nil && converted != "" {
result[key] = converted
continue
}
// 如果转换失败,保留原值
result[key] = timeStr
continue
}
}
// 递归处理嵌套数据
result[key] = convertTimesInMap(value, timezone)
}
return result
case []any:
result := make([]any, len(v))
for i, item := range v {
result[i] = convertTimesInMap(item, timezone)
}
return result
default:
return data
}
}
// convertTimeString 转换时间字符串到指定时区
// 假设数据库存储的时间是 UTC 时区(如:2025-11-22 06:21:25
func convertTimeString(timeStr string, timezone string) any {
if timeStr == "" || timeStr == "null" {
return nil
}
// 如果目标时区是 UTC,直接返回原时间字符串
if timezone == carbon.UTC || timezone == "UTC" {
return timeStr
}
// 加载时区
utcLoc, _ := time.LoadLocation("UTC")
targetLoc, err := time.LoadLocation(timezone)
if err != nil {
return timeStr
}
// 解析时间字符串为 UTC(数据库存储格式)
t, err := time.ParseInLocation(utils.DateTimeFormat, timeStr, utcLoc)
if err != nil {
// 如果标准格式失败,尝试其他格式
t, err = time.Parse(time.RFC3339, timeStr)
if err != nil {
return timeStr
}
// RFC3339 格式可能带时区,转换为 UTC
t = time.Unix(t.Unix(), 0).In(utcLoc)
}
// 转换到目标时区并格式化
return t.In(targetLoc).Format(utils.DateTimeFormat)
}
// convertTimesInValue 使用反射方法处理值(作为备用方案)
func convertTimesInValue(v reflect.Value, timezone string) any {
if !v.IsValid() {
return nil
}
// 处理指针
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil
}
return convertTimesInValue(v.Elem(), timezone)
}
// 处理时间类型
if v.Type() == reflect.TypeOf((*carbon.DateTime)(nil)).Elem() {
dt := v.Interface().(carbon.DateTime)
return dt.SetTimezone(timezone).ToDateTimeString()
}
// 处理 *carbon.DateTime
if v.Type() == reflect.TypeOf((*carbon.DateTime)(nil)) {
if v.IsNil() {
return nil
}
dt := v.Interface().(*carbon.DateTime)
if dt == nil {
return nil
}
return dt.SetTimezone(timezone).ToDateTimeString()
}
// 处理 time.Time
if v.Type() == reflect.TypeOf(time.Time{}) {
t := v.Interface().(time.Time)
dt := carbon.NewDateTime(carbon.Parse(t.Format(utils.DateTimeFormat)))
return dt.SetTimezone(timezone).ToDateTimeString()
}
// 处理 *time.Time
if v.Type() == reflect.TypeOf((*time.Time)(nil)) {
if v.IsNil() {
return nil
}
t := v.Interface().(*time.Time)
if t == nil {
return nil
}
dt := carbon.NewDateTime(carbon.Parse(t.Format(utils.DateTimeFormat)))
return dt.SetTimezone(timezone).ToDateTimeString()
}
// 处理切片
if v.Kind() == reflect.Slice {
if v.IsNil() {
return nil
}
result := make([]any, v.Len())
for i := range result {
result[i] = convertTimesInValue(v.Index(i), timezone)
}
return result
}
// 处理数组
if v.Kind() == reflect.Array {
result := make([]any, v.Len())
for i := range result {
result[i] = convertTimesInValue(v.Index(i), timezone)
}
return result
}
// 处理 map
if v.Kind() == reflect.Map {
if v.IsNil() {
return nil
}
result := make(map[string]any)
for _, key := range v.MapKeys() {
keyStr := key.String()
if key.Kind() == reflect.Interface {
keyStr = reflect.ValueOf(key.Interface()).String()
}
result[keyStr] = convertTimesInValue(v.MapIndex(key), timezone)
}
return result
}
// 处理结构体
if v.Kind() == reflect.Struct {
result := make(map[string]any)
t := v.Type()
for i := range make([]int, v.NumField()) {
field := t.Field(i)
fieldValue := v.Field(i)
// 跳过未导出字段
if !fieldValue.CanInterface() {
continue
}
fieldName := field.Name
// 检查 json tag
if jsonTag := field.Tag.Get("json"); jsonTag != "" && jsonTag != "-" {
// 解析 json tag(处理 "name,omitempty" 格式)
parts := strings.Split(jsonTag, ",")
if len(parts) > 0 && parts[0] != "" {
fieldName = parts[0]
}
}
// 只处理时间相关字段
if isTimeField(fieldName) || isTimeType(fieldValue.Type()) {
result[fieldName] = convertTimesInValue(fieldValue, timezone)
} else {
// 递归处理嵌套结构
if fieldValue.Kind() == reflect.Struct || fieldValue.Kind() == reflect.Ptr || fieldValue.Kind() == reflect.Slice || fieldValue.Kind() == reflect.Map {
result[fieldName] = convertTimesInValue(fieldValue, timezone)
} else {
result[fieldName] = fieldValue.Interface()
}
}
}
return result
}
// 其他类型直接返回
return v.Interface()
}
// isTimeField 检查字段名是否是时间字段
func isTimeField(fieldName string) bool {
return fieldName == "created_at" || fieldName == "updated_at" || fieldName == "deleted_at" ||
fieldName == "CreatedAt" || fieldName == "UpdatedAt" || fieldName == "DeletedAt"
}
// isTimeType 检查类型是否是时间类型
func isTimeType(t reflect.Type) bool {
if t == reflect.TypeOf((*carbon.DateTime)(nil)).Elem() ||
t == reflect.TypeOf((*carbon.DateTime)(nil)) ||
t == reflect.TypeOf(time.Time{}) ||
t == reflect.TypeOf((*time.Time)(nil)) {
return true
}
return false
}
+213
View File
@@ -0,0 +1,213 @@
package helpers
import (
"goravel/app/utils"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/carbon"
"github.com/goravel/framework/support/str"
)
// GetCurrentTimezone 获取当前请求的时区
// 优先从请求头 X-Timezone 或 Timezone 获取
// 如果请求头没有或时区无效,使用配置的默认时区
func GetCurrentTimezone(ctx http.Context) string {
// 优先从 X-Timezone 请求头获取
timezone := ctx.Request().Header("X-Timezone", "")
if timezone == "" {
// 尝试从 Timezone 请求头获取
timezone = ctx.Request().Header("Timezone", "")
}
if timezone == "" {
// 尝试从查询参数获取
timezone = ctx.Request().Input("timezone")
}
// 如果从请求中获取到了时区,规范化并返回
if timezone != "" {
return NormalizeTimezone(timezone)
}
// 如果都没有,使用配置的默认时区
defaultTimezone := facades.Config().GetString("app.timezone", carbon.UTC)
return NormalizeTimezone(defaultTimezone)
}
// isValidTimezone 验证时区是否有效
func isValidTimezone(timezone string) bool {
if timezone == "" {
return false
}
// 尝试加载时区
_, err := time.LoadLocation(timezone)
return err == nil
}
// NormalizeTimezone 规范化时区名称(处理常见别名)
func NormalizeTimezone(timezone string) string {
timezone = str.Of(timezone).Trim().String()
if str.Of(timezone).IsEmpty() {
return carbon.UTC
}
// 转换为标准时区名称
timezoneMap := map[string]string{
"UTC": "UTC",
"GMT": "UTC",
"PST": "America/Los_Angeles",
"PDT": "America/Los_Angeles",
"EST": "America/New_York",
"EDT": "America/New_York",
"CST": "America/Chicago",
"CDT": "America/Chicago",
"MST": "America/Denver",
"MDT": "America/Denver",
"Beijing": "Asia/Shanghai",
"Shanghai": "Asia/Shanghai",
"Hong Kong": "Asia/Hong_Kong",
"Tokyo": "Asia/Tokyo",
"Seoul": "Asia/Seoul",
"Singapore": "Asia/Singapore",
"London": "Europe/London",
"Paris": "Europe/Paris",
"Berlin": "Europe/Berlin",
"Moscow": "Europe/Moscow",
"Sydney": "Australia/Sydney",
"Melbourne": "Australia/Melbourne",
}
if normalized, ok := timezoneMap[timezone]; ok {
return normalized
}
// 如果时区有效,直接返回
if isValidTimezone(timezone) {
return timezone
}
// 默认返回 UTC
return carbon.UTC
}
// ConvertTimeToTimezone 将时间字符串转换为指定时区
// 返回转换后的时间字符串
func ConvertTimeToTimezone(timeStr string, timezone string) string {
if timeStr == "" {
return ""
}
// 规范化时区
timezone = NormalizeTimezone(timezone)
// 解析时间字符串
dt := carbon.Parse(timeStr)
if dt.IsZero() {
return timeStr
}
// 转换时区并返回格式化的字符串
return dt.SetTimezone(timezone).ToDateTimeString()
}
// ConvertTimeByContext 根据请求头中的时区转换时间字符串
// 返回转换后的时间字符串
func ConvertTimeByContext(ctx http.Context, timeStr string) string {
if timeStr == "" {
return ""
}
timezone := GetCurrentTimezone(ctx)
return ConvertTimeToTimezone(timeStr, timezone)
}
// ConvertTimeToUTC 将本地时区的时间字符串转换为 UTC 时间字符串(用于数据库查询)
// timeStr: 前端传入的时间字符串(本地时区格式,如 "2025-11-25 14:00:00"
// ctx: 请求上下文,用于获取当前时区
// 返回: UTC 时间字符串(如 "2025-11-25 06:00:00"
func ConvertTimeToUTC(ctx http.Context, timeStr string) string {
if timeStr == "" {
return ""
}
// 获取当前请求的时区
timezone := GetCurrentTimezone(ctx)
// 如果已经是 UTC,直接返回
if timezone == carbon.UTC || timezone == "UTC" {
return timeStr
}
// 加载时区
targetLoc, err := time.LoadLocation(timezone)
if err != nil {
// 如果时区无效,假设是 UTC
return timeStr
}
utcLoc, _ := time.LoadLocation("UTC")
// 解析时间字符串(假设是本地时区格式)
// 尝试多种格式
formats := []string{
utils.DateTimeFormat,
utils.DateTimeFormatT,
utils.DateTimeFormatMs,
utils.DateTimeFormatTZ,
time.RFC3339,
}
var t time.Time
var parseErr error
for _, format := range formats {
t, parseErr = time.ParseInLocation(format, timeStr, targetLoc)
if parseErr == nil {
break
}
}
if parseErr != nil {
// 如果所有格式都失败,尝试使用 carbon 解析
dt := carbon.Parse(timeStr)
if dt.IsZero() {
return timeStr
}
// 假设解析的时间是本地时区,转换为 UTC
return dt.SetTimezone(carbon.UTC).ToDateTimeString()
}
// 转换为 UTC 并格式化
return t.In(utcLoc).Format(utils.DateTimeFormat)
}
// GetTimeQueryParam 获取并转换时间查询参数(统一处理时间查询)
// 自动将前端传入的本地时区时间转换为 UTC 时间用于数据库查询
// 支持常见的时间查询参数名称:start_time, end_time, created_at_start, created_at_end, updated_at_start, updated_at_end
func GetTimeQueryParam(ctx http.Context, paramName string) string {
timeStr := ctx.Request().Query(paramName, "")
if timeStr == "" {
return ""
}
return ConvertTimeToUTC(ctx, timeStr)
}
// FormatTimeWithTimezone 使用指定时区格式化 time.Time
func FormatTimeWithTimezone(t time.Time, timezone string) string {
if t.IsZero() {
return ""
}
if timezone == "" {
timezone = "UTC"
}
loc, err := time.LoadLocation(timezone)
if err != nil {
return t.Format("2006-01-02 15:04:05")
}
return t.In(loc).Format("2006-01-02 15:04:05")
}
// FormatCarbonWithTimezone 使用指定时区格式化 Carbon 时间
func FormatCarbonWithTimezone(t *carbon.DateTime, timezone string) string {
if t == nil || t.IsZero() {
return ""
}
return FormatTimeWithTimezone(t.StdTime(), timezone)
}
+58
View File
@@ -0,0 +1,58 @@
package helpers
import (
"errors"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support/str"
"goravel/app/models"
)
// GetUserFromContext 从 context 中获取 user 对象
// 如果 context 中没有 user 或类型不匹配,返回错误
func GetUserFromContext(ctx http.Context) (*models.User, error) {
userValue := ctx.Value("user")
if userValue == nil {
return nil, errors.New("user not found in context")
}
// 尝试值类型
if user, ok := userValue.(models.User); ok {
return &user, nil
}
// 尝试指针类型
if userPtr, ok := userValue.(*models.User); ok {
return userPtr, nil
}
return nil, errors.New("invalid user type in context")
}
// GetUserIDFromContext 从 context 中获取 user ID
// 如果 context 中没有 user 或类型不匹配,返回 0 和错误
func GetUserIDFromContext(ctx http.Context) (uint, error) {
user, err := GetUserFromContext(ctx)
if err != nil {
return 0, err
}
return user.ID, nil
}
// GetTokenFromHeader 从请求头中获取token
// 支持从Authorization header或URL参数中获取
func GetTokenFromHeader(ctx http.Context) string {
token := ctx.Request().Header("Authorization", "")
// 如果 Header 中没有 token,尝试从 URL 参数中获取
if str.Of(token).IsEmpty() {
token = ctx.Request().Query("_token", "")
}
// 移除Bearer前缀(如果有)
token = str.Of(token).ChopStart("Bearer ").Trim().String()
return token
}
+175
View File
@@ -0,0 +1,175 @@
package helpers
import (
"strings"
"github.com/goravel/framework/contracts/http"
)
// ParseUserAgent 解析User-Agent字符串,返回浏览器和操作系统信息
func ParseUserAgent(userAgent string) (browser, os string) {
if userAgent == "" {
return "Unknown", "Unknown"
}
ua := strings.ToLower(userAgent)
// 解析浏览器
browser = parseBrowser(ua)
// 解析操作系统
os = parseOS(ua)
return browser, os
}
// parseBrowser 解析浏览器类型
func parseBrowser(ua string) string {
// Chrome
if strings.Contains(ua, "chrome") && !strings.Contains(ua, "edg") && !strings.Contains(ua, "opr") {
// 提取Chrome版本
if idx := strings.Index(ua, "chrome/"); idx != -1 {
version := extractVersion(ua, idx+7)
return "Chrome " + version
}
return "Chrome"
}
// Edge
if strings.Contains(ua, "edg") {
if idx := strings.Index(ua, "edg/"); idx != -1 {
version := extractVersion(ua, idx+4)
return "Edge " + version
}
return "Edge"
}
// Firefox
if strings.Contains(ua, "firefox") {
if idx := strings.Index(ua, "firefox/"); idx != -1 {
version := extractVersion(ua, idx+8)
return "Firefox " + version
}
return "Firefox"
}
// Safari
if strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome") {
if idx := strings.Index(ua, "version/"); idx != -1 {
version := extractVersion(ua, idx+8)
return "Safari " + version
}
return "Safari"
}
// Opera
if strings.Contains(ua, "opr") || strings.Contains(ua, "opera") {
if idx := strings.Index(ua, "opr/"); idx != -1 {
version := extractVersion(ua, idx+4)
return "Opera " + version
}
if idx := strings.Index(ua, "version/"); idx != -1 {
version := extractVersion(ua, idx+8)
return "Opera " + version
}
return "Opera"
}
// IE
if strings.Contains(ua, "msie") || strings.Contains(ua, "trident") {
if idx := strings.Index(ua, "msie "); idx != -1 {
version := extractVersion(ua, idx+5)
return "IE " + version
}
return "IE"
}
return "Unknown"
}
// parseOS 解析操作系统
func parseOS(ua string) string {
// Windows
if strings.Contains(ua, "windows") {
if strings.Contains(ua, "windows nt 10.0") || strings.Contains(ua, "windows nt 6.3") {
return "Windows 10/11"
}
if strings.Contains(ua, "windows nt 6.2") {
return "Windows 8"
}
if strings.Contains(ua, "windows nt 6.1") {
return "Windows 7"
}
if strings.Contains(ua, "windows nt 6.0") {
return "Windows Vista"
}
if strings.Contains(ua, "windows nt 5.1") {
return "Windows XP"
}
return "Windows"
}
// macOS
if strings.Contains(ua, "mac os x") || strings.Contains(ua, "macintosh") {
if idx := strings.Index(ua, "mac os x "); idx != -1 {
version := extractVersion(ua, idx+9)
return "macOS " + version
}
return "macOS"
}
// iOS
if strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad") || strings.Contains(ua, "ipod") {
if idx := strings.Index(ua, "os "); idx != -1 {
version := extractVersion(ua, idx+3)
version = strings.ReplaceAll(version, "_", ".")
return "iOS " + version
}
return "iOS"
}
// Android
if strings.Contains(ua, "android") {
if idx := strings.Index(ua, "android "); idx != -1 {
version := extractVersion(ua, idx+8)
return "Android " + version
}
return "Android"
}
// Linux
if strings.Contains(ua, "linux") {
return "Linux"
}
return "Unknown"
}
// extractVersion 从User-Agent字符串中提取版本号
func extractVersion(ua string, startIdx int) string {
if startIdx >= len(ua) {
return ""
}
var version strings.Builder
for i := startIdx; i < len(ua); i++ {
c := ua[i]
if (c >= '0' && c <= '9') || c == '.' || c == '_' {
version.WriteByte(c)
} else {
break
}
}
result := version.String()
if len(result) > 10 {
return result[:10]
}
return result
}
// GetBrowserAndOS 从HTTP上下文获取浏览器和操作系统信息
func GetBrowserAndOS(ctx http.Context) (browser, os string) {
userAgent := ctx.Request().Header("User-Agent", "")
return ParseUserAgent(userAgent)
}
+97
View File
@@ -0,0 +1,97 @@
package helpers
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
"github.com/goravel/framework/support/str"
"github.com/spf13/cast"
)
// GetIntQuery 获取并验证整数查询参数
// 如果参数无效或不存在,返回默认值
func GetIntQuery(ctx http.Context, key string, defaultValue int) int {
value := ctx.Request().Query(key, "")
if value == "" {
return defaultValue
}
result := cast.ToInt(value)
if result < 1 {
return defaultValue
}
return result
}
// GetUintQuery 获取并验证无符号整数查询参数
// 如果参数无效或不存在,返回默认值
func GetUintQuery(ctx http.Context, key string, defaultValue uint) uint {
value := ctx.Request().Query(key, "")
if value == "" {
return defaultValue
}
result := cast.ToUint(value)
if result == 0 {
return defaultValue
}
return result
}
// GetUintRoute 获取并验证路由中的无符号整数参数
// 如果参数无效或不存在,返回 0
func GetUintRoute(ctx http.Context, key string) uint {
value := ctx.Request().Route(key)
return cast.ToUint(value)
}
// ParseIDsFromString 从逗号分隔的字符串中解析 ID 列表
// 返回去重后的 ID 列表
func ParseIDsFromString(idStr string) []uint {
if str.Of(idStr).IsEmpty() {
return []uint{}
}
var ids []uint
idMap := make(map[uint]bool)
// 分割字符串
idStrs := str.Of(idStr).Split(",")
for _, idStr := range idStrs {
idStr = str.Of(idStr).Trim().String()
if str.Of(idStr).IsEmpty() {
continue
}
id := cast.ToUint(idStr)
if id > 0 && !idMap[id] {
idMap[id] = true
ids = append(ids, id)
}
}
return ids
}
// ConvertUintSliceToAny 将 uint 切片转换为 []any
// 用于 ORM 的 WhereIn 查询
func ConvertUintSliceToAny(ids []uint) []any {
if len(ids) == 0 {
return []any{}
}
result := make([]any, len(ids))
for i, id := range ids {
result[i] = id
}
return result
}
// PrepareNumericFieldForValidation 在 PrepareForValidation 中准备数字字段
// 将指定的数字字段转换为字符串,以便 in 规则能正确验证
// 使用 cast.ToString 自动处理所有数字类型转换(int, int8-int64, uint, uint8-uint64, float32, float64
// 用法:在 PrepareForValidation 方法中调用此函数处理需要 in 验证的数字字段
// 示例:return PrepareNumericFieldForValidation(data, "status")
func PrepareNumericFieldForValidation(data validation.Data, fieldName string) error {
if val, exist := data.Get(fieldName); exist {
return data.Set(fieldName, cast.ToString(val))
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package http
import (
"github.com/goravel/framework/contracts/http"
httpmiddleware "github.com/goravel/framework/http/middleware"
// sessionmiddleware "github.com/goravel/framework/session/middleware" // 已禁用
appmiddleware "goravel/app/http/middleware"
)
type Kernel struct {
}
// The application's global HTTP middleware stack.
// These middleware are run during every request to your application.
func (kernel Kernel) Middleware() []http.Middleware {
return []http.Middleware{
appmiddleware.Cors(), // CORS 跨域处理(需要在最前面处理预检请求)
appmiddleware.Blacklist(), // 黑名单检查
appmiddleware.Trace(),
httpmiddleware.Throttle("global"),
// sessionmiddleware.StartSession(), // 已禁用:项目使用 JWT 认证,不需要 Session
}
}
View File
+51
View File
@@ -0,0 +1,51 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/http/helpers"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/utils"
)
func Blacklist() http.Middleware {
return func(ctx http.Context) {
// 排除登录接口,避免管理员被封禁后无法登录
path := ctx.Request().Path()
if path == "/api/admin/login" || path == "/api/admin/login/captcha" {
ctx.Request().Next()
return
}
// 获取真实IP地址
realIP := helpers.GetRealIP(ctx)
// 查询所有启用的黑名单记录
var blacklists []models.Blacklist
if err := facades.Orm().Query().Where("status", 1).Get(&blacklists); err != nil {
// 如果查询失败,记录错误但继续处理请求(避免影响系统正常运行)
facades.Log().Errorf("Blacklist middleware: Failed to query blacklists: %v", err)
ctx.Request().Next()
return
}
// 检查IP是否在黑名单中
for _, blacklist := range blacklists {
if utils.IsIPInBlacklist(realIP, blacklist.IP) {
// IP在黑名单中,拒绝访问
facades.Log().Warningf("Blacklist middleware: IP %s blocked by blacklist ID %d", realIP, blacklist.ID)
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": http.StatusForbidden,
"message": trans.Get(ctx, "ip_blocked"),
}).Abort()
return
}
}
// IP不在黑名单中,继续处理请求
ctx.Request().Next()
}
}
+157
View File
@@ -0,0 +1,157 @@
package middleware
import (
"slices"
"strconv"
"strings"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
// Cors CORS 中间件,处理跨域请求
func Cors() http.Middleware {
return func(ctx http.Context) {
// 获取请求路径
path := ctx.Request().Path()
// 检查是否是 WebSocket 升级请求
isWebSocket := strings.ToLower(ctx.Request().Header("Upgrade", "")) == "websocket" ||
strings.ToLower(ctx.Request().Header("Connection", "")) == "upgrade"
// 获取 CORS 配置的路径列表
corsPaths := facades.Config().Get("cors.paths", []string{}).([]string)
// 检查当前路径是否需要 CORS 处理
needCors := false
if len(corsPaths) == 0 {
// 如果没有配置路径,默认对所有路径启用
needCors = true
} else {
for _, corsPath := range corsPaths {
// 支持通配符匹配
if strings.HasSuffix(corsPath, "*") {
prefix := strings.TrimSuffix(corsPath, "*")
if strings.HasPrefix(path, prefix) {
needCors = true
break
}
} else if path == corsPath {
needCors = true
break
}
}
}
// WebSocket 请求直接放行,不需要 CORS 处理(WebSocket 有自己的协议)
if isWebSocket {
ctx.Request().Next()
return
}
if !needCors {
ctx.Request().Next()
return
}
// 获取 CORS 配置
allowedOrigins := facades.Config().Get("cors.allowed_origins", []string{"*"}).([]string)
allowedMethods := facades.Config().Get("cors.allowed_methods", []string{"*"}).([]string)
allowedHeaders := facades.Config().Get("cors.allowed_headers", []string{"*"}).([]string)
exposedHeaders := facades.Config().Get("cors.exposed_headers", []string{}).([]string)
maxAge := facades.Config().GetInt("cors.max_age", 0)
supportsCredentials := facades.Config().GetBool("cors.supports_credentials", false)
// 获取请求的 Origin
origin := ctx.Request().Header("Origin", "")
// 检查是否允许该 Origin
allowed := false
var allowedOrigin string
if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" {
// 允许所有源
allowed = true
allowedOrigin = "*"
} else if origin != "" {
// 检查是否在允许列表中
if slices.Contains(allowedOrigins, origin) {
allowed = true
allowedOrigin = origin
}
}
// 处理预检请求 (OPTIONS) - 必须在设置其他头之前处理
if ctx.Request().Method() == "OPTIONS" {
// 对于预检请求,必须设置 CORS 头
// 即使 origin 不在允许列表中,也要返回 CORS 头(只是不设置 Access-Control-Allow-Origin
// 这样浏览器才能正确判断,而不是因为状态码问题而失败
// 创建响应对象并设置所有 CORS 头
response := ctx.Response()
if allowed && origin != "" {
// Origin 在允许列表中
response.Header("Access-Control-Allow-Origin", allowedOrigin)
if supportsCredentials && allowedOrigin != "*" {
response.Header("Access-Control-Allow-Credentials", "true")
}
} else if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" {
// 配置允许所有源
response.Header("Access-Control-Allow-Origin", "*")
} else if origin != "" {
// Origin 不在允许列表中,不设置 Access-Control-Allow-Origin
// 浏览器会拒绝请求,但至少不会因为状态码问题而失败
}
// 设置允许的方法(对于预检请求,这些头必须设置)
methodsStr := "*"
if len(allowedMethods) > 0 && allowedMethods[0] != "*" {
methodsStr = strings.Join(allowedMethods, ", ")
}
response.Header("Access-Control-Allow-Methods", methodsStr)
// 设置允许的请求头
headersStr := "*"
if len(allowedHeaders) > 0 && allowedHeaders[0] != "*" {
headersStr = strings.Join(allowedHeaders, ", ")
}
response.Header("Access-Control-Allow-Headers", headersStr)
// 设置暴露的响应头
if len(exposedHeaders) > 0 {
response.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
}
// 设置预检请求的缓存时间
if maxAge > 0 {
response.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
}
// 返回 204 No Content 并终止请求处理
// 对于 OPTIONS 请求,返回空响应体,状态码为 204
// 使用 Json 方法返回空对象,然后调用 Abort() 终止请求
_ = response.Json(http.StatusNoContent, http.Json{}).Abort()
return
}
// 对于非预检请求,设置 CORS 响应头
if allowed && origin != "" {
ctx.Response().Header("Access-Control-Allow-Origin", allowedOrigin)
if supportsCredentials && allowedOrigin != "*" {
ctx.Response().Header("Access-Control-Allow-Credentials", "true")
}
} else if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" && origin != "" {
// 如果配置允许所有源,且请求有 origin,设置 CORS 头
ctx.Response().Header("Access-Control-Allow-Origin", "*")
}
// 设置暴露的响应头(非预检请求)
if len(exposedHeaders) > 0 {
ctx.Response().Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
}
// 继续处理请求
ctx.Request().Next()
}
}
+21
View File
@@ -0,0 +1,21 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
func DevelopmentOnly() http.Middleware {
return func(ctx http.Context) {
env := facades.Config().Get("app.env", "production")
if env != "local" && env != "development" {
ctx.Response().Json(http.StatusForbidden, map[string]any{
"code": 403,
"message": "This feature is only available in development mode",
})
ctx.Request().Abort()
return
}
ctx.Request().Next()
}
}
+211
View File
@@ -0,0 +1,211 @@
package middleware
import (
"net"
"strings"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
// Domain 域名验证中间件
func Domain(configValueOrDomains ...any) http.Middleware {
return func(ctx http.Context) {
var domains []string
// 如果没有参数,不验证(允许所有域名)
if len(configValueOrDomains) == 0 {
ctx.Request().Next()
return
}
// 解析配置值的辅助函数
parseConfigValue := func(value any) []string {
if value == nil {
return nil
}
switch v := value.(type) {
case []string:
return v
case string:
if v == "" {
return nil
}
// 分割逗号分隔的域名
domainsList := strings.Split(v, ",")
result := make([]string, 0, len(domainsList))
for _, d := range domainsList {
d = strings.TrimSpace(d)
if d != "" {
result = append(result, d)
}
}
return result
case []any:
result := make([]string, 0, len(v))
for _, item := range v {
if str, ok := item.(string); ok && str != "" {
result = append(result, str)
}
}
return result
default:
return nil
}
}
// 处理参数
for _, param := range configValueOrDomains {
if param == nil {
continue
}
switch v := param.(type) {
case []string:
// 如果是字符串数组,直接使用
if len(v) > 0 {
domains = append(domains, v...)
}
case string:
// 如果是字符串,可能是单个域名或配置键
if v != "" {
// 先尝试作为配置键读取
configValue := facades.Config().Get(v, nil)
if configValue != nil {
// 如果配置存在,解析配置值
parsedDomains := parseConfigValue(configValue)
if len(parsedDomains) > 0 {
domains = append(domains, parsedDomains...)
continue
}
}
// 否则当作域名
domains = append(domains, v)
}
case []any:
// 如果是 any 数组,递归处理
for _, item := range v {
if str, ok := item.(string); ok && str != "" {
domains = append(domains, str)
}
}
default:
// 其他类型,尝试解析为配置值
parsedDomains := parseConfigValue(v)
if len(parsedDomains) > 0 {
domains = append(domains, parsedDomains...)
}
}
}
// 如果没有指定允许的域名,允许所有域名访问(直接放行)
if len(domains) == 0 {
ctx.Request().Next()
return
}
// 获取请求的 Host
// 优先从 X-Forwarded-Host 获取(适用于反向代理场景)
host := ctx.Request().Header("X-Forwarded-Host", "")
if host == "" {
// 使用框架提供的 Host() 方法获取(推荐方式)
host = ctx.Request().Host()
}
// 如果 X-Forwarded-Host 包含多个值(逗号分隔),取第一个
if host != "" && strings.Contains(host, ",") {
host = strings.TrimSpace(strings.Split(host, ",")[0])
}
// 调试日志:记录获取到的 Host 值
if facades.Config().GetBool("app.debug", false) {
facades.Log().Debugf("Domain middleware: Host detection - X-Forwarded-Host: %s, Host(): %s, Final host: %s",
ctx.Request().Header("X-Forwarded-Host", ""),
ctx.Request().Host(),
host)
}
// 规范化 Host(移除端口号,转换为小写)
normalizedHost := normalizeHost(host)
// 调试日志:记录规范化后的 Host 和配置的域名
if facades.Config().GetBool("app.debug", false) {
facades.Log().Debugf("Domain middleware: Normalized host: %s, Configured domains: %v", normalizedHost, domains)
}
// 检查是否在允许的域名列表中
allowed := false
var matchedDomain string
for _, allowedDomain := range domains {
normalizedAllowed := normalizeHost(allowedDomain)
// 支持精确匹配和通配符匹配
if normalizedHost == normalizedAllowed || matchDomain(normalizedHost, normalizedAllowed) {
allowed = true
matchedDomain = allowedDomain
break
}
}
if !allowed {
// 域名不在允许列表中,拒绝访问
// facades.Log().Warningf("Domain middleware: Access denied. Request host: %s (normalized: %s), Allowed domains: %v", host, normalizedHost, domains)
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": http.StatusForbidden,
"message": "Access denied: domain not allowed",
}).Abort()
return
}
// 记录匹配的域名(仅在调试模式下)
if facades.Config().GetBool("app.debug", false) {
facades.Log().Debugf("Domain middleware: Access allowed. Request host: %s, Matched domain: %s", normalizedHost, matchedDomain)
}
// 域名验证通过,继续处理请求
ctx.Request().Next()
}
}
// normalizeHost 规范化域名(移除端口号,转换为小写,去除前后空格)
func normalizeHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return ""
}
// 移除端口号
if hostname, _, err := net.SplitHostPort(host); err == nil {
host = hostname
}
return host
}
// matchDomain 域名匹配,支持通配符
// 例如:*.example.com 可以匹配 a.example.com, b.example.com 等
func matchDomain(host, pattern string) bool {
if pattern == "" {
return false
}
// 如果模式以 * 开头,进行通配符匹配
if after, ok := strings.CutPrefix(pattern, "*."); ok {
// 移除 *.
suffix := after
// 检查 host 是否以 .suffix 结尾
if strings.HasSuffix(host, "."+suffix) || host == suffix {
return true
}
}
// 如果模式以 * 结尾,进行前缀匹配
if before, ok := strings.CutSuffix(pattern, ".*"); ok {
prefix := before
if strings.HasPrefix(host, prefix+".") || host == prefix {
return true
}
}
return false
}
+124
View File
@@ -0,0 +1,124 @@
package middleware
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/logger"
)
// min 返回两个整数中的较小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
func Jwt() http.Middleware {
return func(ctx http.Context) {
// 如果路径是api/admin前缀,使用admin guard
path := ctx.Request().Path()
pathStr := str.Of(path)
if pathStr.IsEmpty() || (!pathStr.StartsWith("/api/admin") && !pathStr.StartsWith("/admin")) {
ctx.Request().Next()
return
}
token := ctx.Request().Header("Authorization", "")
// 如果 Header 中没有 token,尝试从 URL 参数中获取(用于 SSE 等不支持自定义 headers 的场景)
if str.Of(token).IsEmpty() {
token = ctx.Request().Query("_token", "")
}
if str.Of(token).IsEmpty() {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
// 移除Bearer前缀(如果有)
token = str.Of(token).ChopStart("Bearer ").Trim().String()
if token == "" {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
// 从数据库查找token
tokenService := services.NewTokenServiceImpl()
accessToken, err := tokenService.FindToken(token)
if err != nil {
// token查找失败或已过期
logger.ErrorfHTTP(ctx, "JWT middleware: FindToken error: %v, token prefix: %s", err, token[:min(20, len(token))])
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "invalid_token"),
}).Abort()
return
}
if accessToken == nil {
logger.ErrorfHTTP(ctx, "JWT middleware: accessToken is nil, token prefix: %s", token[:min(20, len(token))])
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "invalid_token"),
}).Abort()
return
}
// 检查token类型
if accessToken.TokenableType != "admin" {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "invalid_token"),
}).Abort()
return
}
// 查询用户信息
var admin models.Admin
if err := facades.Orm().Query().Where("id", accessToken.TokenableID).First(&admin); err != nil {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "user_not_found"),
}).Abort()
return
}
// 更新最后使用时间
_ = tokenService.UpdateLastUsedAt(token)
// 滑动过期:如果token有过期时间,每次请求时自动延长过期时间
if accessToken.ExpiresAt != nil {
ttl := facades.Config().GetInt("jwt.ttl", 60) // 默认60分钟
if ttl > 0 {
newExpiresAt := time.Now().Add(time.Duration(ttl) * time.Minute)
// 更新token的过期时间
_, _ = facades.Orm().Query().
Model(&models.PersonalAccessToken{}).
Where("id", accessToken.ID).
Update("expires_at", newExpiresAt)
}
}
// 将用户信息存储到context中,供后续中间件使用
ctx.WithValue("admin", admin)
ctx.WithValue("token", accessToken)
// facades.Log().Debugf("JWT middleware: admin set in context, ID: %d, Username: %s", admin.ID, admin.Username)
ctx.Request().Next()
}
}
+18
View File
@@ -0,0 +1,18 @@
package middleware
import (
httpcontract "github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/utils"
)
// Lang 多语言中间件,从请求头获取语言
func Lang() httpcontract.Middleware {
return func(ctx httpcontract.Context) {
// 使用通用工具函数获取语言
lang := utils.GetCurrentLanguage(ctx)
facades.App().SetLocale(ctx, lang)
ctx.Request().Next()
}
}
+39
View File
@@ -0,0 +1,39 @@
package middleware
import (
"context"
"github.com/opentracing/opentracing-go"
"github.com/opentracing/opentracing-go/ext"
"github.com/goravel/framework/contracts/http"
)
const (
OpentracingTracer = "opentracing_tracer"
OpentracingCtx = "opentracing_ctx"
)
func Opentracing(tracer opentracing.Tracer) http.Middleware {
return func(ctx http.Context) {
var parentSpan opentracing.Span
spCtx, err := opentracing.GlobalTracer().Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(ctx.Request().Headers()))
if err != nil {
parentSpan = tracer.StartSpan(ctx.Request().Path())
defer parentSpan.Finish()
} else {
parentSpan = opentracing.StartSpan(
ctx.Request().Path(),
opentracing.ChildOf(spCtx),
opentracing.Tag{Key: string(ext.Component), Value: "HTTP"},
ext.SpanKindRPCServer,
)
defer parentSpan.Finish()
}
ctx.WithValue(OpentracingTracer, tracer)
ctx.WithValue(OpentracingCtx, opentracing.ContextWithSpan(context.Background(), parentSpan))
ctx.Request().Next()
}
}
+139
View File
@@ -0,0 +1,139 @@
package middleware
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/http/helpers"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
"goravel/app/utils/logger"
"goravel/app/utils/traceid"
)
// OperationLog 操作日志中间件
func OperationLog() http.Middleware {
return func(ctx http.Context) {
systemLogService := services.NewSystemLogService()
startTime := time.Now()
// 获取请求信息
method := ctx.Request().Method()
path := ctx.Request().Path()
ip := ctx.Request().Ip()
userAgent := ctx.Request().Header("User-Agent", "")
// 获取请求参数(排除敏感信息)
var requestBody string
if method == "POST" || method == "PUT" || method == "PATCH" {
// 获取所有输入参数
inputs := make(map[string]any)
// 记录所有非敏感参数
allInputs := ctx.Request().All()
for key, value := range allInputs {
// 使用工具函数检查是否是敏感字段
if utils.IsSensitiveField(key) {
inputs[key] = "***"
} else {
inputs[key] = value
}
}
if data, err := json.Marshal(inputs); err == nil {
requestBody = string(data)
}
}
// 获取管理员ID(从JWT中间件设置的context中获取)
var adminID uint
if admin, err := helpers.GetAdminFromContext(ctx); err == nil {
adminID = admin.ID
}
// 继续处理请求
ctx.Request().Next()
// 计算耗时
duration := int(time.Since(startTime).Milliseconds())
// 只记录新增、修改、删除操作(POST、PUT、PATCH、DELETE),排除 GET 请求
// 同时排除登录和info接口,以及分片上传的进度查询(GET请求)
// 排除代码生成器相关操作
// 对于分片上传,只记录 merge 操作(最终完成上传),排除 init 和 upload 操作
if (method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE") &&
path != "/api/admin/login" && path != "/api/admin/info" &&
!strings.HasPrefix(path, "/api/admin/code-generator/") {
// 排除分片上传的中间操作(init 和 upload),只记录 merge(最终完成上传)
if path == "/api/admin/attachments/chunk" {
action := ctx.Request().Input("action", "")
if action == "" {
action = ctx.Request().Query("action", "")
}
// 只记录 merge 操作,排除 init、upload 和 progress
if action != "merge" {
return
}
}
// 在请求处理后再获取一次管理员ID(确保JWT中间件已执行)
// 如果之前没有获取到,再次尝试从context获取
if adminID == 0 {
if admin, err := helpers.GetAdminFromContext(ctx); err == nil {
adminID = admin.ID
}
}
// 默认状态为成功
status := uint8(1)
var errorMsg string
// 在goroutine之前保存所有需要的数据,避免context问题
savedAdminID := adminID
savedMethod := method
savedPath := path
savedIP := ip
savedUserAgent := userAgent
savedRequestBody := requestBody
savedDuration := duration
// 提前获取 traceCtx,用于日志记录
traceCtx := traceid.DeriveContextFromHTTP(ctx)
// 生成操作标题(只使用权限标识)
title := utils.GetOperationTitleFromContext(ctx)
if title == "operation.unknown" {
// 如果无法生成标题,记录调试日志
logger.ErrorfContext(traceCtx, "Failed to generate operation title, method: %s, path: %s", savedMethod, savedPath)
}
operationLog := models.OperationLog{
AdminID: savedAdminID,
Method: savedMethod,
Path: savedPath,
Title: title,
IP: savedIP,
UserAgent: savedUserAgent,
Request: savedRequestBody,
Status: status,
ErrorMsg: errorMsg,
Duration: savedDuration,
}
// 异步记录日志,避免影响响应速度
go func(ctx context.Context) {
if err := facades.Orm().Query().Create(&operationLog); err != nil {
_ = systemLogService.Record(ctx, "error", "operation-log", "failed to persist operation log", map[string]any{
"error": err.Error(),
"path": savedPath,
})
logger.ErrorfContext(ctx, "Failed to create operation log: %v", err)
}
}(traceCtx)
}
}
}
+256
View File
@@ -0,0 +1,256 @@
package middleware
import (
"strings"
"github.com/goravel/framework/contracts/http"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/errorlog"
"goravel/app/utils/logger"
)
// Permission 权限验证中间件
func Permission() http.Middleware {
return func(ctx http.Context) {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": 401,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
admin, ok := adminValue.(models.Admin)
if !ok {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": 401,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
// 加载管理员的角色、权限等关联数据
adminService := services.NewAdminServiceImpl()
if err := adminService.LoadRelationsWithPermissions(&admin); err != nil {
logger.ErrorfHTTP(ctx, "permission middleware load relations failed: %v", err)
errorlog.RecordHTTP(ctx, "permission", "Failed to load admin relations with permissions", map[string]any{
"error": err.Error(),
"admin_id": admin.ID,
"path": ctx.Request().Path(),
}, "Load admin relations failed: %v", err)
_ = ctx.Response().Json(http.StatusInternalServerError, http.Json{
"code": 500,
"message": trans.Get(ctx, "load_permissions_failed"),
}).Abort()
return
}
// 检查是否是超级管理员
// 拥有 super-admin 角色的管理员(包括超级管理员和开发者管理员)都跳过权限“拦截”,但仍然参与权限匹配,用于生成操作标题
isSuperAdmin := false
for _, role := range admin.Roles {
if role.Slug == "super-admin" && role.Status == 1 {
isSuperAdmin = true
break
}
}
// 获取当前请求的方法和路径
method := ctx.Request().Method()
path := ctx.Request().Path()
// 收集所有角色的权限(已通过预加载获取)
var allPermissions []models.Permission
for _, role := range admin.Roles {
if role.Status == 1 {
allPermissions = append(allPermissions, role.Permissions...)
}
}
// 检查是否有权限,并记录匹配的权限标识
hasPermission := false
var matchedPermissionSlug string
var menuDisabled bool
for _, perm := range allPermissions {
if perm.Status == 1 {
// 检查方法匹配
if perm.Method == "" || perm.Method == method {
// 检查路径匹配(支持通配符)
if perm.Path == "" || perm.Path == path || matchPath(perm.Path, path) {
// 检查关联菜单的状态(如果权限关联了菜单)
// 如果权限没有关联菜单(MenuID = 0),则允许访问
// 如果权限关联了菜单,需要检查菜单状态是否为启用(status = 1)
if perm.MenuID == 0 {
// 权限没有关联菜单,允许访问
hasPermission = true
matchedPermissionSlug = perm.Slug
break
} else if perm.Menu.ID > 0 {
// 权限关联了菜单,检查菜单状态
if perm.Menu.Status == 1 {
// 菜单状态为启用,允许访问
hasPermission = true
matchedPermissionSlug = perm.Slug
break
} else {
// 菜单状态为关闭,记录但继续查找其他权限
menuDisabled = true
}
}
// 如果菜单没有加载(perm.Menu.ID == 0),为了安全起见,不允许访问
// 这种情况应该很少发生,因为我们已经预加载了菜单
}
}
}
}
// 非超级管理员且无匹配权限时拦截;超级管理员即使无匹配权限也放行
if !hasPermission && !isSuperAdmin {
// 如果是因为菜单状态为关闭而禁止访问,返回更具体的错误信息
if menuDisabled {
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": 403,
"message": trans.Get(ctx, "menu_disabled"),
}).Abort()
} else {
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": 403,
"message": trans.Get(ctx, "no_permission"),
}).Abort()
}
return
}
// 将匹配的权限标识存储到 context 中,供操作日志使用
if matchedPermissionSlug != "" {
ctx.WithValue("permission_slug", matchedPermissionSlug)
}
ctx.Request().Next()
}
}
// matchPath 路径匹配,支持通配符
// 支持的模式:
// 1. 精确匹配:/api/admin/roles 匹配 /api/admin/roles
// 2. 末尾通配符:/api/admin/roles/* 匹配 /api/admin/roles/1
// 3. 中间通配符:/api/admin/attachments/*/display-name 匹配 /api/admin/attachments/1/display-name
func matchPath(pattern, path string) bool {
if pattern == path {
return true
}
// 如果模式不包含通配符,直接返回 false
if !contains(pattern, '*') {
return false
}
// 将模式按 * 分割成多个部分
parts := splitPattern(pattern)
if len(parts) == 0 {
return false
}
// 如果模式以 * 开头,需要特殊处理
if pattern[0] == '*' {
// 检查路径是否以模式的剩余部分结尾
if len(parts) > 1 {
suffix := parts[1]
return len(path) >= len(suffix) && path[len(path)-len(suffix):] == suffix
}
return true
}
// 如果模式以 * 结尾
if pattern[len(pattern)-1] == '*' {
prefix := pattern[:len(pattern)-1]
if len(path) >= len(prefix) {
pathPrefix := path[:len(prefix)]
if pathPrefix == prefix {
// 如果前缀以 / 结尾,路径必须比前缀长(即后面还有内容)
if len(prefix) > 0 && prefix[len(prefix)-1] == '/' {
return len(path) > len(prefix)
}
// 如果前缀不以 / 结尾,路径可以等于或长于前缀
return true
}
}
return false
}
// 处理中间有通配符的情况,如 /api/admin/attachments/*/display-name
// 将模式按 * 分割
patternParts := splitPattern(pattern)
if len(patternParts) < 2 {
return false
}
// 过滤掉 "*" 标记,只保留实际的部分
var actualParts []string
for _, part := range patternParts {
if part != "*" {
actualParts = append(actualParts, part)
}
}
if len(actualParts) < 2 {
return false
}
// 检查路径是否以第一部分开头
firstPart := actualParts[0]
if len(path) < len(firstPart) || path[:len(firstPart)] != firstPart {
return false
}
// 检查路径是否以最后一部分结尾
lastPart := actualParts[len(actualParts)-1]
if len(path) < len(lastPart) || path[len(path)-len(lastPart):] != lastPart {
return false
}
// 检查中间部分是否存在(通配符匹配任意内容)
// 路径应该是:firstPart + 任意内容 + lastPart
remainingPath := path[len(firstPart) : len(path)-len(lastPart)]
// 确保中间部分不为空(至少有一个字符,通常是数字ID)
return len(remainingPath) > 0
}
// contains 检查字符串是否包含指定字符
func contains(s string, c byte) bool {
for i := range s {
if s[i] == c {
return true
}
}
return false
}
// splitPattern 按 * 分割模式字符串
func splitPattern(pattern string) []string {
var parts []string
var current strings.Builder
for i := range pattern {
if pattern[i] == '*' {
if current.Len() > 0 {
parts = append(parts, current.String())
current.Reset()
}
parts = append(parts, "*")
} else {
current.WriteByte(pattern[i])
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
+40
View File
@@ -0,0 +1,40 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"goravel/app/http/helpers"
)
// TimezoneQuery 时区查询参数转换中间件
// 自动将查询参数中的时间字段从本地时区转换为 UTC 时间用于数据库查询
func TimezoneQuery() http.Middleware {
return func(ctx http.Context) {
// 定义需要转换的时间查询参数名称
timeParams := []string{
"start_time",
"end_time",
"created_at_start",
"created_at_end",
"updated_at_start",
"updated_at_end",
"deleted_at_start",
"deleted_at_end",
}
// 转换每个时间参数
for _, paramName := range timeParams {
timeStr := ctx.Request().Query(paramName, "")
if timeStr != "" {
// 转换为 UTC 时间并存储在 context 中
utcTime := helpers.ConvertTimeToUTC(ctx, timeStr)
// 将转换后的时间存储在 context 中,供控制器使用
ctx.WithValue("timezone_query_"+paramName, utcTime)
}
}
// 继续处理请求
ctx.Request().Next()
}
}
+17
View File
@@ -0,0 +1,17 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"goravel/app/utils/traceid"
)
// Trace middleware ensures every request carries a trace id and mirrors it in response headers.
func Trace() http.Middleware {
return func(ctx http.Context) {
traceID := traceid.EnsureHTTPContext(ctx, "")
ctx.Response().Header(traceid.HeaderName(), traceID)
ctx.Request().Next()
}
}
+75
View File
@@ -0,0 +1,75 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"goravel/app/http/trans"
"goravel/app/models"
)
// UserJwt C端用户JWT认证中间件(使用Goravel标准Auth
func UserJwt() http.Middleware {
return func(ctx http.Context) {
// 如果路径是api/user前缀,使用user guard
path := ctx.Request().Path()
pathStr := str.Of(path)
if pathStr.IsEmpty() || !pathStr.StartsWith("/api/user") {
ctx.Request().Next()
return
}
// 使用Goravel标准Auth解析token
if _, err := facades.Auth(ctx).Guard("user").Parse(ctx.Request().Header("Authorization", "")); err != nil {
// 如果Header中没有token,尝试从URL参数中获取
if token := ctx.Request().Query("_token", ""); token != "" {
if _, err := facades.Auth(ctx).Guard("user").Parse(token); err != nil {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "invalid_token"),
}).Abort()
return
}
} else {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
}
// 获取用户信息
var user models.User
if err := facades.Auth(ctx).Guard("user").User(&user); err != nil {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "user_not_found"),
}).Abort()
return
}
if user.ID == 0 {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "user_not_found"),
}).Abort()
return
}
// 检查用户状态
if user.Status == 0 {
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": http.StatusForbidden,
"message": trans.Get(ctx, "account_disabled"),
}).Abort()
return
}
// 将用户信息存储到context中,供后续中间件使用
ctx.WithValue("user", user)
ctx.Request().Next()
}
}
+67
View File
@@ -0,0 +1,67 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type AdminCreate struct {
Username string `form:"username" json:"username"`
Password string `form:"password" json:"password"`
Nickname string `form:"nickname" json:"nickname"`
Email string `form:"email" json:"email"`
Phone string `form:"phone" json:"phone"`
DepartmentID uint `form:"department_id" json:"department_id"`
Status uint8 `form:"status" json:"status"`
RoleIDs []uint `form:"role_ids" json:"role_ids"`
}
func (r *AdminCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *AdminCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"username": "required|min_len:3|max_len:50",
"password": "required|min_len:6|max_len:50",
"nickname": "max_len:50",
"email": "email|max_len:100",
"phone": "max_len:20",
"status": "in:0,1",
}
}
func (r *AdminCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"username.required": trans.Get(ctx, "validation_username_required"),
"username.min_len": trans.Get(ctx, "validation_username_min"),
"username.max_len": trans.Get(ctx, "validation_username_max"),
"password.required": trans.Get(ctx, "validation_password_required"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
"password.max_len": trans.Get(ctx, "validation_password_max"),
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *AdminCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"username": trans.Get(ctx, "validation_username"),
"password": trans.Get(ctx, "validation_password"),
"nickname": trans.Get(ctx, "validation_nickname"),
"email": trans.Get(ctx, "validation_email"),
"phone": trans.Get(ctx, "validation_phone"),
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *AdminCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// 将 status 字段转换为字符串,以便 in 规则能正确验证
return helpers.PrepareNumericFieldForValidation(data, "status")
}
+60
View File
@@ -0,0 +1,60 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type AdminUpdate struct {
Nickname string `form:"nickname" json:"nickname"`
Email string `form:"email" json:"email"`
Phone string `form:"phone" json:"phone"`
Password string `form:"password" json:"password"`
DepartmentID uint `form:"department_id" json:"department_id"`
Status uint8 `form:"status" json:"status"`
RoleIDs []uint `form:"role_ids" json:"role_ids"`
}
func (r *AdminUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *AdminUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"nickname": "max_len:50",
"email": "email|max_len:100",
"phone": "max_len:20",
"password": "min_len:6|max_len:50",
"status": "in:0,1",
}
}
func (r *AdminUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
"password.max_len": trans.Get(ctx, "validation_password_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *AdminUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"nickname": trans.Get(ctx, "validation_nickname"),
"email": trans.Get(ctx, "validation_email"),
"phone": trans.Get(ctx, "validation_phone"),
"password": trans.Get(ctx, "validation_password"),
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *AdminUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// 将 status 字段转换为字符串,以便 in 规则能正确验证
return helpers.PrepareNumericFieldForValidation(data, "status")
}
+41
View File
@@ -0,0 +1,41 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type ArticleCreate struct {
Name string `form:"name" json:"name"`
Status string `form:"status" json:"status"`
}
func (r *ArticleCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *ArticleCreate) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
"name": "required",
"status": "",
}
return rules
}
func (r *ArticleCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "validation_name_required"),
"status.required": trans.Get(ctx, "validation_status_required"),
}
}
func (r *ArticleCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"status": trans.Get(ctx, "validation_status"),
}
}
+41
View File
@@ -0,0 +1,41 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type ArticleUpdate struct {
Name *string `form:"name" json:"name"`
Status *string `form:"status" json:"status"`
}
func (r *ArticleUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *ArticleUpdate) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
"name": "required",
"status": "",
}
return rules
}
func (r *ArticleUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "validation_name_required"),
"status.required": trans.Get(ctx, "validation_status_required"),
}
}
func (r *ArticleUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"status": trans.Get(ctx, "validation_status"),
}
}
@@ -0,0 +1,45 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type BlacklistCreate struct {
IP string `form:"ip" json:"ip"`
Remark string `form:"remark" json:"remark"`
Status uint8 `form:"status" json:"status"`
}
func (r *BlacklistCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *BlacklistCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"ip": "required",
"status": "in:0,1",
}
}
func (r *BlacklistCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"ip.required": trans.Get(ctx, "ip_address_required"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *BlacklistCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"ip": trans.Get(ctx, "validation_ip"),
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *BlacklistCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,42 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type BlacklistUpdate struct {
IP string `form:"ip" json:"ip"`
Remark string `form:"remark" json:"remark"`
Status uint8 `form:"status" json:"status"`
}
func (r *BlacklistUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *BlacklistUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"status": "in:0,1",
}
}
func (r *BlacklistUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *BlacklistUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *BlacklistUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,68 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type DepartmentCreate struct {
ParentID uint `form:"parent_id" json:"parent_id"`
Name string `form:"name" json:"name"`
Code string `form:"code" json:"code"`
Leader string `form:"leader" json:"leader"`
Phone string `form:"phone" json:"phone"`
Email string `form:"email" json:"email"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
Remark string `form:"remark" json:"remark"`
}
func (r *DepartmentCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *DepartmentCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "required|max_len:50",
"code": "max_len:50",
"leader": "max_len:50",
"phone": "max_len:20",
"email": "email|max_len:100",
"status": "in:0,1",
"remark": "max_len:500",
}
}
func (r *DepartmentCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "department_name_required"),
"name.max_len": trans.Get(ctx, "validation_name_max"),
"code.max_len": trans.Get(ctx, "validation_code_max"),
"leader.max_len": trans.Get(ctx, "validation_leader_max"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
}
}
func (r *DepartmentCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"code": trans.Get(ctx, "validation_code"),
"leader": trans.Get(ctx, "validation_leader"),
"phone": trans.Get(ctx, "validation_phone"),
"email": trans.Get(ctx, "validation_email"),
"status": trans.Get(ctx, "validation_status"),
"remark": trans.Get(ctx, "validation_remark"),
}
}
func (r *DepartmentCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,67 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type DepartmentUpdate struct {
ParentID uint `form:"parent_id" json:"parent_id"`
Name string `form:"name" json:"name"`
Code string `form:"code" json:"code"`
Leader string `form:"leader" json:"leader"`
Phone string `form:"phone" json:"phone"`
Email string `form:"email" json:"email"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
Remark string `form:"remark" json:"remark"`
}
func (r *DepartmentUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *DepartmentUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "max_len:50",
"code": "max_len:50",
"leader": "max_len:50",
"phone": "max_len:20",
"email": "email|max_len:100",
"status": "in:0,1",
"remark": "max_len:500",
}
}
func (r *DepartmentUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.max_len": trans.Get(ctx, "validation_name_max"),
"code.max_len": trans.Get(ctx, "validation_code_max"),
"leader.max_len": trans.Get(ctx, "validation_leader_max"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
}
}
func (r *DepartmentUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"code": trans.Get(ctx, "validation_code"),
"leader": trans.Get(ctx, "validation_leader"),
"phone": trans.Get(ctx, "validation_phone"),
"email": trans.Get(ctx, "validation_email"),
"status": trans.Get(ctx, "validation_status"),
"remark": trans.Get(ctx, "validation_remark"),
}
}
func (r *DepartmentUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,67 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type DictionaryCreate struct {
Type string `form:"type" json:"type"`
Label string `form:"label" json:"label"`
Value string `form:"value" json:"value"`
TranslationKey string `form:"translation_key" json:"translation_key"`
Description string `form:"description" json:"description"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
Remark string `form:"remark" json:"remark"`
}
func (r *DictionaryCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *DictionaryCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"type": "required|max_len:50",
"label": "required|max_len:50",
"value": "required|max_len:100",
"translation_key": "max_len:255",
"description": "max_len:255",
"status": "in:0,1",
"remark": "max_len:500",
}
}
func (r *DictionaryCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"type.required": trans.Get(ctx, "dictionary_type_required"),
"type.max_len": trans.Get(ctx, "validation_type_max"),
"label.required": trans.Get(ctx, "validation_label_required"),
"label.max_len": trans.Get(ctx, "validation_label_max"),
"value.required": trans.Get(ctx, "validation_value_required"),
"value.max_len": trans.Get(ctx, "validation_value_max"),
"translation_key.max_len": trans.Get(ctx, "validation_translation_key_max"),
"description.max_len": trans.Get(ctx, "validation_description_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
}
}
func (r *DictionaryCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"type": trans.Get(ctx, "validation_type"),
"label": trans.Get(ctx, "validation_label"),
"value": trans.Get(ctx, "validation_value"),
"translation_key": trans.Get(ctx, "validation_translation_key"),
"description": trans.Get(ctx, "validation_description"),
"status": trans.Get(ctx, "validation_status"),
"remark": trans.Get(ctx, "validation_remark"),
}
}
func (r *DictionaryCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,64 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type DictionaryUpdate struct {
Type string `form:"type" json:"type"`
Label string `form:"label" json:"label"`
Value string `form:"value" json:"value"`
TranslationKey string `form:"translation_key" json:"translation_key"`
Description string `form:"description" json:"description"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
Remark string `form:"remark" json:"remark"`
}
func (r *DictionaryUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *DictionaryUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"type": "max_len:50",
"label": "max_len:50",
"value": "max_len:100",
"translation_key": "max_len:255",
"description": "max_len:255",
"status": "in:0,1",
"remark": "max_len:500",
}
}
func (r *DictionaryUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"type.max_len": trans.Get(ctx, "validation_type_max"),
"label.max_len": trans.Get(ctx, "validation_label_max"),
"value.max_len": trans.Get(ctx, "validation_value_max"),
"translation_key.max_len": trans.Get(ctx, "validation_translation_key_max"),
"description.max_len": trans.Get(ctx, "validation_description_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
}
}
func (r *DictionaryUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"type": trans.Get(ctx, "validation_type"),
"label": trans.Get(ctx, "validation_label"),
"value": trans.Get(ctx, "validation_value"),
"translation_key": trans.Get(ctx, "validation_translation_key"),
"description": trans.Get(ctx, "validation_description"),
"status": trans.Get(ctx, "validation_status"),
"remark": trans.Get(ctx, "validation_remark"),
}
}
func (r *DictionaryUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
+41
View File
@@ -0,0 +1,41 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type Login struct {
Username string `form:"username" json:"username"`
Password string `form:"password" json:"password"`
CaptchaID string `form:"captcha_id" json:"captcha_id"`
CaptchaAnswer string `form:"captcha_answer" json:"captcha_answer"`
GoogleCode string `form:"google_code" json:"google_code"` // 谷歌验证码
}
func (r *Login) Authorize(ctx http.Context) error {
return nil
}
func (r *Login) Rules(ctx http.Context) map[string]string {
return map[string]string{
"username": "required",
"password": "required|min_len:6",
}
}
func (r *Login) Messages(ctx http.Context) map[string]string {
return map[string]string{
"username.required": trans.Get(ctx, "validation_username_required"),
"password.required": trans.Get(ctx, "validation_password_required"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
}
}
func (r *Login) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"username": trans.Get(ctx, "validation_username"),
"password": trans.Get(ctx, "validation_password"),
}
}
+110
View File
@@ -0,0 +1,110 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type MenuCreate struct {
ParentID uint `form:"parent_id" json:"parent_id"`
Title string `form:"title" json:"title"`
Slug string `form:"slug" json:"slug"`
Icon string `form:"icon" json:"icon"`
Path string `form:"path" json:"path"`
Component string `form:"component" json:"component"`
Permission string `form:"permission" json:"permission"`
Type uint8 `form:"type" json:"type"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
IsHidden uint8 `form:"is_hidden" json:"is_hidden"`
LinkType uint8 `form:"link_type" json:"link_type"`
OpenType uint8 `form:"open_type" json:"open_type"`
}
func (r *MenuCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *MenuCreate) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
"title": "required|max_len:50",
"slug": "required|max_len:50",
"icon": "max_len:50",
"component": "max_len:255",
"permission": "max_len:100",
"type": "in:1,2,3",
"status": "in:0,1",
"is_hidden": "in:0,1",
"link_type": "in:1,2",
"open_type": "in:1,2",
}
// 根据 link_type 动态设置 path 的验证规则
linkType := ctx.Request().Input("link_type")
if linkType == "2" {
// 外部链接:需要验证为完整的 URL
rules["path"] = "required|max_len:1000|full_url"
} else {
// 内部页面:只需要必填和长度验证
rules["path"] = "required|max_len:1000"
// 内部页面不验证 open_type
delete(rules, "open_type")
}
return rules
}
func (r *MenuCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"title.required": trans.Get(ctx, "menu_title_required"),
"title.max_len": trans.Get(ctx, "validation_title_max"),
"slug.required": trans.Get(ctx, "menu_slug_required"),
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
"icon.max_len": trans.Get(ctx, "validation_icon_max"),
"path.required": trans.Get(ctx, "menu_path_required"),
"path.max_len": trans.Get(ctx, "validation_path_max"),
"path.full_url": trans.Get(ctx, "validation_path_url_invalid"),
"component.max_len": trans.Get(ctx, "validation_component_max"),
"permission.max_len": trans.Get(ctx, "validation_permission_max"),
"type.in": trans.Get(ctx, "validation_menu_type_in"),
"status.in": trans.Get(ctx, "validation_status_in"),
"is_hidden.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *MenuCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"title": trans.Get(ctx, "validation_title"),
"slug": trans.Get(ctx, "validation_slug"),
"icon": trans.Get(ctx, "validation_icon"),
"path": trans.Get(ctx, "validation_path"),
"component": trans.Get(ctx, "validation_component"),
"permission": trans.Get(ctx, "validation_permission"),
"type": trans.Get(ctx, "validation_type"),
"status": trans.Get(ctx, "validation_status"),
"is_hidden": trans.Get(ctx, "validation_is_hidden"),
}
}
func (r *MenuCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// 将数字字段转换为字符串,以便 in 规则能正确验证
if err := helpers.PrepareNumericFieldForValidation(data, "type"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "status"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "is_hidden"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "link_type"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "open_type"); err != nil {
return err
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type MenuUpdate struct {
ParentID uint `form:"parent_id" json:"parent_id"`
Title string `form:"title" json:"title"`
Slug string `form:"slug" json:"slug"`
Icon string `form:"icon" json:"icon"`
Path string `form:"path" json:"path"`
Component string `form:"component" json:"component"`
Permission string `form:"permission" json:"permission"`
Type uint8 `form:"type" json:"type"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
IsHidden uint8 `form:"is_hidden" json:"is_hidden"`
LinkType uint8 `form:"link_type" json:"link_type"`
OpenType uint8 `form:"open_type" json:"open_type"`
}
func (r *MenuUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *MenuUpdate) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
"title": "max_len:50",
"slug": "max_len:50",
"icon": "max_len:50",
"component": "max_len:255",
"permission": "max_len:100",
"type": "in:1,2,3",
"status": "in:0,1",
"is_hidden": "in:0,1",
"link_type": "in:1,2",
"open_type": "in:1,2",
}
// 根据 link_type 动态设置 path 的验证规则
linkType := ctx.Request().Input("link_type")
if linkType == "2" {
// 外部链接:需要验证为完整的 URL
rules["path"] = "max_len:1000|full_url"
} else {
// 内部页面:只需要长度验证
rules["path"] = "max_len:1000"
// 内部页面不验证 open_type
delete(rules, "open_type")
}
return rules
}
func (r *MenuUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"title.max_len": trans.Get(ctx, "validation_title_max"),
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
"icon.max_len": trans.Get(ctx, "validation_icon_max"),
"path.max_len": trans.Get(ctx, "validation_path_max"),
"path.full_url": trans.Get(ctx, "validation_path_url_invalid"),
"component.max_len": trans.Get(ctx, "validation_component_max"),
"permission.max_len": trans.Get(ctx, "validation_permission_max"),
"type.in": trans.Get(ctx, "validation_menu_type_in"),
"status.in": trans.Get(ctx, "validation_status_in"),
"is_hidden.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *MenuUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"title": trans.Get(ctx, "validation_title"),
"slug": trans.Get(ctx, "validation_slug"),
"icon": trans.Get(ctx, "validation_icon"),
"path": trans.Get(ctx, "validation_path"),
"component": trans.Get(ctx, "validation_component"),
"permission": trans.Get(ctx, "validation_permission"),
"type": trans.Get(ctx, "validation_type"),
"status": trans.Get(ctx, "validation_status"),
"is_hidden": trans.Get(ctx, "validation_is_hidden"),
}
}
func (r *MenuUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// 将数字字段转换为字符串,以便 in 规则能正确验证
if err := helpers.PrepareNumericFieldForValidation(data, "type"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "status"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "is_hidden"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "link_type"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "open_type"); err != nil {
return err
}
return nil
}
@@ -0,0 +1,66 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type PaymentMethodCreate struct {
Name string `form:"name" json:"name"`
Code string `form:"code" json:"code"`
Type string `form:"type" json:"type"`
Config map[string]any `form:"config" json:"config"`
IsActive bool `form:"is_active" json:"is_active"`
Sort int `form:"sort" json:"sort"`
Description string `form:"description" json:"description"`
}
func (r *PaymentMethodCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *PaymentMethodCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "required|max_len:50",
"code": "required|max_len:20",
"type": "required|max_len:20",
"config": "required",
"is_active": "boolean",
"sort": "min:0",
}
}
func (r *PaymentMethodCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "validation_name_required"),
"name.max_len": trans.Get(ctx, "validation_name_max"),
"code.required": trans.Get(ctx, "validation_code_required"),
"code.max_len": trans.Get(ctx, "validation_code_max"),
"type.required": trans.Get(ctx, "validation_type_required"),
"type.max_len": trans.Get(ctx, "validation_type_max"),
"config.required": trans.Get(ctx, "validation_config_required"),
"is_active.boolean": trans.Get(ctx, "validation_boolean"),
"sort.min": trans.Get(ctx, "validation_min"),
}
}
func (r *PaymentMethodCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"code": trans.Get(ctx, "validation_code"),
"type": trans.Get(ctx, "validation_type"),
"config": trans.Get(ctx, "validation_config"),
"is_active": trans.Get(ctx, "validation_is_active"),
"sort": trans.Get(ctx, "validation_sort"),
}
}
// func (r *PaymentMethodCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// // sort 字段使用 integer|min:0 规则,不需要转换为字符串
// // 如果 sort 为空或不存在,设置为默认值 0
// if val, exist := data.Get("sort"); !exist || val == nil || val == "" {
// return data.Set("sort", 0)
// }
// return nil
// }
@@ -0,0 +1,52 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type PaymentMethodUpdate struct {
Name string `form:"name" json:"name"`
Config map[string]any `form:"config" json:"config"`
IsActive bool `form:"is_active" json:"is_active"`
Sort int `form:"sort" json:"sort"`
Description string `form:"description" json:"description"`
}
func (r *PaymentMethodUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *PaymentMethodUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "required|max_len:50",
"is_active": "boolean",
"sort": "min:0",
}
}
func (r *PaymentMethodUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "validation_name_required"),
"name.max_len": trans.Get(ctx, "validation_name_max"),
"is_active.boolean": trans.Get(ctx, "validation_boolean"),
"sort.min": trans.Get(ctx, "validation_min"),
}
}
func (r *PaymentMethodUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"is_active": trans.Get(ctx, "validation_is_active"),
"sort": trans.Get(ctx, "validation_sort"),
}
}
// func (r *PaymentMethodUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// // return helpers.PrepareNumericFieldForValidation(data, "sort")
// if val, exist := data.Get("sort"); !exist || val == nil || val == "" {
// return data.Set("sort", 0)
// }
// return nil
// }
+34
View File
@@ -0,0 +1,34 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type ResetPassword struct {
Password string `form:"password" json:"password"`
}
func (r *ResetPassword) Authorize(ctx http.Context) error {
return nil
}
func (r *ResetPassword) Rules(ctx http.Context) map[string]string {
return map[string]string{
"password": "required|min_len:6",
}
}
func (r *ResetPassword) Messages(ctx http.Context) map[string]string {
return map[string]string{
"password.required": trans.Get(ctx, "validation_password_required"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
}
}
func (r *ResetPassword) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"password": trans.Get(ctx, "validation_password"),
}
}
+55
View File
@@ -0,0 +1,55 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type RoleCreate struct {
Name string `form:"name" json:"name"`
Slug string `form:"slug" json:"slug"`
Description string `form:"description" json:"description"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
}
func (r *RoleCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *RoleCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "required|max_len:50",
"slug": "required|max_len:50",
"description": "max_len:255",
"status": "in:0,1",
}
}
func (r *RoleCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "validation_name_required"),
"name.max_len": trans.Get(ctx, "validation_name_max"),
"slug.required": trans.Get(ctx, "validation_slug_required"),
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
"description.max_len": trans.Get(ctx, "validation_description_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *RoleCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"slug": trans.Get(ctx, "validation_slug"),
"description": trans.Get(ctx, "validation_description"),
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *RoleCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
+53
View File
@@ -0,0 +1,53 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type RoleUpdate struct {
Name string `form:"name" json:"name"`
Slug string `form:"slug" json:"slug"`
Description string `form:"description" json:"description"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
}
func (r *RoleUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *RoleUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "max_len:50",
"slug": "max_len:50",
"description": "max_len:255",
"status": "in:0,1",
}
}
func (r *RoleUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.max_len": trans.Get(ctx, "validation_name_max"),
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
"description.max_len": trans.Get(ctx, "validation_description_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *RoleUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"slug": trans.Get(ctx, "validation_slug"),
"description": trans.Get(ctx, "validation_description"),
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *RoleUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,44 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type UpdatePassword struct {
OldPassword string `form:"old_password" json:"old_password"`
NewPassword string `form:"new_password" json:"new_password"`
ConfirmPassword string `form:"confirm_password" json:"confirm_password"`
}
func (r *UpdatePassword) Authorize(ctx http.Context) error {
return nil
}
func (r *UpdatePassword) Rules(ctx http.Context) map[string]string {
return map[string]string{
"old_password": "required",
"new_password": "required|min_len:6",
"confirm_password": "required|same:new_password",
}
}
func (r *UpdatePassword) Messages(ctx http.Context) map[string]string {
return map[string]string{
"old_password.required": trans.Get(ctx, "validation_old_password_required"),
"new_password.required": trans.Get(ctx, "validation_new_password_required"),
"new_password.min_len": trans.Get(ctx, "validation_password_min"),
"confirm_password.required": trans.Get(ctx, "validation_confirm_password_required"),
"confirm_password.same": trans.Get(ctx, "validation_password_not_match"),
}
}
func (r *UpdatePassword) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"old_password": trans.Get(ctx, "validation_old_password"),
"new_password": trans.Get(ctx, "validation_new_password"),
"confirm_password": trans.Get(ctx, "validation_confirm_password"),
}
}
+67
View File
@@ -0,0 +1,67 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type UserCreate struct {
Username string `form:"username" json:"username"`
Password string `form:"password" json:"password"`
Nickname string `form:"nickname" json:"nickname"`
Email string `form:"email" json:"email"`
Phone string `form:"phone" json:"phone"`
Status uint8 `form:"status" json:"status"`
}
func (r *UserCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *UserCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"username": "required|min_len:3|max_len:50|not_exists:users,username",
"password": "required|min_len:6|max_len:50",
"nickname": "max_len:50",
"email": "email|max_len:100|not_exists:users,email",
"phone": "max_len:20",
"status": "in:0,1",
}
}
func (r *UserCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"username.required": trans.Get(ctx, "validation_username_required"),
"username.min_len": trans.Get(ctx, "validation_username_min"),
"username.max_len": trans.Get(ctx, "validation_username_max"),
"username.not_exists": trans.Get(ctx, "username_exists"),
"password.required": trans.Get(ctx, "validation_password_required"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
"password.max_len": trans.Get(ctx, "validation_password_max"),
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"email.not_exists": trans.Get(ctx, "email_already_exists"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *UserCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"username": trans.Get(ctx, "attribute_username"),
"password": trans.Get(ctx, "attribute_password"),
"nickname": trans.Get(ctx, "attribute_nickname"),
"email": trans.Get(ctx, "attribute_email"),
"phone": trans.Get(ctx, "attribute_phone"),
"status": trans.Get(ctx, "attribute_status"),
}
}
func (r *UserCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
+59
View File
@@ -0,0 +1,59 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type UserUpdate struct {
Nickname string `form:"nickname" json:"nickname"`
Email string `form:"email" json:"email"`
Phone string `form:"phone" json:"phone"`
Password string `form:"password" json:"password"`
Status uint8 `form:"status" json:"status"`
}
func (r *UserUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *UserUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"nickname": "max_len:50",
"email": "email|max_len:100",
"phone": "max_len:20",
"password": "min_len:6|max_len:50",
"status": "in:0,1",
}
}
func (r *UserUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
"password.max_len": trans.Get(ctx, "validation_password_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *UserUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"nickname": trans.Get(ctx, "attribute_nickname"),
"email": trans.Get(ctx, "attribute_email"),
"phone": trans.Get(ctx, "attribute_phone"),
"password": trans.Get(ctx, "attribute_password"),
"status": trans.Get(ctx, "attribute_status"),
}
}
func (r *UserUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type UserLogin struct {
Username string `form:"username" json:"username"`
Password string `form:"password" json:"password"`
}
func (r *UserLogin) Authorize(ctx http.Context) error {
return nil
}
func (r *UserLogin) Rules(ctx http.Context) map[string]string {
return map[string]string{
"username": "required",
"password": "required",
}
}
func (r *UserLogin) Messages(ctx http.Context) map[string]string {
return map[string]string{
"username.required": trans.Get(ctx, "validation_username_required"),
"password.required": trans.Get(ctx, "validation_password_required"),
}
}
func (r *UserLogin) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"username": trans.Get(ctx, "attribute_username"),
"password": trans.Get(ctx, "attribute_password"),
}
}
func (r *UserLogin) PrepareForValidation(ctx http.Context, data validation.Data) error {
return nil
}
+62
View File
@@ -0,0 +1,62 @@
package api
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type UserRegister struct {
Username string `form:"username" json:"username"`
Password string `form:"password" json:"password"`
Nickname string `form:"nickname" json:"nickname"`
Email string `form:"email" json:"email"`
Phone string `form:"phone" json:"phone"`
}
func (r *UserRegister) Authorize(ctx http.Context) error {
return nil
}
func (r *UserRegister) Rules(ctx http.Context) map[string]string {
return map[string]string{
"username": "required|min_len:3|max_len:50|not_exists:users,username",
"password": "required|min_len:6|max_len:50",
"nickname": "max_len:50",
"email": "email|max_len:100|not_exists:users,email",
"phone": "max_len:20",
}
}
func (r *UserRegister) Messages(ctx http.Context) map[string]string {
return map[string]string{
"username.required": trans.Get(ctx, "validation_username_required"),
"username.min_len": trans.Get(ctx, "validation_username_min"),
"username.max_len": trans.Get(ctx, "validation_username_max"),
"username.not_exists": trans.Get(ctx, "username_exists"),
"password.required": trans.Get(ctx, "validation_password_required"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
"password.max_len": trans.Get(ctx, "validation_password_max"),
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"email.not_exists": trans.Get(ctx, "email_already_exists"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
}
}
func (r *UserRegister) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"username": trans.Get(ctx, "attribute_username"),
"password": trans.Get(ctx, "attribute_password"),
"nickname": trans.Get(ctx, "attribute_nickname"),
"email": trans.Get(ctx, "attribute_email"),
"phone": trans.Get(ctx, "attribute_phone"),
}
}
func (r *UserRegister) PrepareForValidation(ctx http.Context, data validation.Data) error {
return nil
}
+685
View File
@@ -0,0 +1,685 @@
package response
import (
"reflect"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/trans"
"goravel/app/services"
"goravel/app/utils/errorlog"
"goravel/app/utils/traceid"
)
// 错误日志信息的 context key
const (
errorLogModuleKey = "error_log_module"
errorLogMessageKey = "error_log_message"
errorLogAttributesKey = "error_log_attributes"
)
// Success 成功响应(支持多语言,自动包含 trace_id)
// messageKey 可选,如果不传则使用默认值 "success"
// 使用方式:
// - response.Success(ctx)
// - response.Success(ctx, data)
// - response.Success(ctx, "custom_message", data)
func Success(ctx http.Context, args ...any) http.Response {
var messageKey string
var data any
// 智能识别参数:如果第一个参数是 string 且长度合理(<=50),则认为是 messageKey
if len(args) > 0 {
if msgKey, ok := args[0].(string); ok && len(msgKey) <= 50 {
// 方式1:传了 messageKey
messageKey = msgKey
if len(args) > 1 {
data = args[1]
}
} else {
// 方式2:没传 messageKey,第一个参数就是 data
messageKey = "success" // 默认值
data = args[0]
}
} else {
// 没有参数,使用默认值
messageKey = "success"
}
message := trans.Get(ctx, messageKey)
response := http.Json{
"code": 200,
"message": message,
}
// 自动包含 trace_id,方便前端追踪
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
response["trace_id"] = traceID
}
// 如果有数据,添加到响应中
if data != nil {
// 转换时间字段到对应时区
convertedData := helpers.ConvertTimesInData(ctx, data)
response["data"] = convertedData
}
return ctx.Response().Success().Json(response)
}
// SuccessWithHeader 成功响应(支持多语言和自定义Header,自动包含 trace_id
func SuccessWithHeader(ctx http.Context, messageKey string, headerKey, headerValue string, data ...http.Json) http.Response {
message := trans.Get(ctx, messageKey)
response := http.Json{
"code": 200,
"message": message,
}
// 自动包含 trace_id,方便前端追踪
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
response["trace_id"] = traceID
}
if len(data) > 0 {
// 转换时间字段到对应时区
convertedData := helpers.ConvertTimesInData(ctx, data[0])
if convertedMap, ok := convertedData.(map[string]any); ok {
response["data"] = http.Json(convertedMap)
} else {
response["data"] = convertedData
}
}
return ctx.Response().Header(headerKey, headerValue).Success().Json(response)
}
// Error 错误响应(支持多语言,自动包含 trace_id 和 error_code
// 支持两种调用方式:
// 1. response.Error(ctx, code, messageKey) - 使用翻译键
// 2. response.Error(ctx, code, err) - 自动检测 BusinessError 并处理占位符替换
//
// 当 code >= 500 时,如果 context 中包含错误日志信息,会自动记录日志
func Error(ctx http.Context, code int, messageOrErr any) http.Response {
var message string
var messageKey string
// 判断第二个参数是 error 还是 string
switch v := messageOrErr.(type) {
case error:
// 如果是 error,检查是否是 BusinessError
if businessErr, ok := apperrors.GetBusinessError(v); ok {
// 使用 GetFormattedMessage 自动处理翻译和占位符替换
message = businessErr.GetFormattedMessage(ctx)
messageKey = businessErr.Code
} else {
// 普通错误,直接使用错误消息
message = v.Error()
messageKey = "operation_failed"
}
case string:
// 如果是 string,当作翻译键处理
messageKey = v
message = trans.Get(ctx, messageKey)
default:
// 其他类型,转换为字符串
messageKey = "operation_failed"
message = trans.Get(ctx, messageKey)
}
response := http.Json{
"code": code,
"message": message,
"error_code": messageKey, // 添加错误码字段,方便前端判断
}
// 自动包含 trace_id,方便前端显示和用户报告错误
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
response["trace_id"] = traceID
}
// 系统级错误(500+)自动记录日志(如果 context 中有日志信息)
if code >= 500 {
if module, ok := ctx.Value(errorLogModuleKey).(string); ok && module != "" {
logMessage := ctx.Value(errorLogMessageKey)
if logMsg, ok := logMessage.(string); ok && logMsg != "" {
attributes := ctx.Value(errorLogAttributesKey)
var attrs map[string]any
if attributes != nil {
if attrsMap, ok := attributes.(map[string]any); ok {
attrs = attrsMap
}
}
errorlog.RecordHTTP(ctx, module, logMsg, attrs, "%s: %s", module, logMsg)
}
}
}
return ctx.Response().Json(code, response)
}
// SetErrorLog 设置错误日志信息到 context(用于系统级错误)
// 在调用 response.Error 之前调用此函数,Error 函数会自动记录日志
func SetErrorLog(ctx http.Context, module, logMessage string, attributes map[string]any) {
ctx.WithValue(errorLogModuleKey, module)
ctx.WithValue(errorLogMessageKey, logMessage)
if attributes != nil {
ctx.WithValue(errorLogAttributesKey, attributes)
}
}
// ErrorWithLog 错误响应并自动记录日志(用于系统级错误)
// 支持多种调用方式,自动推断参数:
//
// 最简洁方式(推荐):
// - response.ErrorWithLog(ctx, "module", err)
// - response.ErrorWithLog(ctx, "module", err, map[string]any{"extra": "value"})
//
// 完整方式:
// - response.ErrorWithLog(ctx, code, messageKey, module, logMessage, err)
// - response.ErrorWithLog(ctx, code, messageKey, module, logMessage, err, map[string]any{...})
func ErrorWithLog(ctx http.Context, args ...any) http.Response {
// 智能识别调用方式
if len(args) == 0 {
return Error(ctx, http.StatusInternalServerError, "operation_failed")
}
// 方式1:最简洁方式 - ErrorWithLog(ctx, "module", err) 或 ErrorWithLog(ctx, "module", err, attrs)
if len(args) >= 2 {
if module, ok := args[0].(string); ok {
var err error
var attributes map[string]any
// 查找 error 和 attributes
for i := 1; i < len(args); i++ {
switch v := args[i].(type) {
case error:
if err == nil {
err = v
}
case map[string]any:
if attributes == nil {
attributes = v
}
}
}
if err == nil {
return Error(ctx, http.StatusInternalServerError, "operation_failed")
}
// 自动生成日志消息
logMessage := err.Error()
if len(logMessage) > 100 {
logMessage = logMessage[:100] + "..."
}
// 设置属性
if attributes == nil {
attributes = make(map[string]any)
}
if _, exists := attributes["error"]; !exists {
attributes["error"] = err.Error()
}
// 自动记录日志
SetErrorLog(ctx, module, logMessage, attributes)
return Error(ctx, http.StatusInternalServerError, "operation_failed")
}
}
// 方式2:完整方式 - ErrorWithLog(ctx, code, messageKey, module, logMessage, ...)
if len(args) >= 4 {
code, codeOk := args[0].(int)
messageKey, msgOk := args[1].(string)
module, modOk := args[2].(string)
logMessage, logOk := args[3].(string)
if codeOk && msgOk && modOk && logOk {
var attributes map[string]any
var err error
// 解析剩余参数
for i := 4; i < len(args); i++ {
switch v := args[i].(type) {
case error:
if err == nil {
err = v
}
case map[string]any:
if attributes == nil {
attributes = v
}
}
}
// 设置属性
if attributes == nil {
attributes = make(map[string]any)
}
if err != nil {
if _, exists := attributes["error"]; !exists {
attributes["error"] = err.Error()
}
}
// 系统级错误(500+)设置日志信息,Error 函数会自动记录
if code >= 500 {
SetErrorLog(ctx, module, logMessage, attributes)
}
return Error(ctx, code, messageKey)
}
}
// 无法识别参数格式,返回通用错误
return Error(ctx, http.StatusInternalServerError, "operation_failed")
}
// ErrorWithLogAuto 超简洁版本:自动推断所有参数
// 只需要传入 module 和 err,其他参数自动推断
// 默认使用 HTTP 500 状态码和通用的错误消息
//
// 使用方式:
// - response.ErrorWithLogAuto(ctx, "operation-log", err)
// - response.ErrorWithLogAuto(ctx, "operation-log", err, map[string]any{"extra": "value"})
func ErrorWithLogAuto(ctx http.Context, module string, args ...any) http.Response {
var err error
var attributes map[string]any
// 解析参数
for i := len(args) - 1; i >= 0; i-- {
switch v := args[i].(type) {
case error:
if err == nil {
err = v
}
case map[string]any:
if attributes == nil {
attributes = v
}
}
}
// 如果没有 error,返回通用错误
if err == nil {
return Error(ctx, http.StatusInternalServerError, "operation_failed")
}
// 如果没有提供 attributes,创建一个
if attributes == nil {
attributes = make(map[string]any)
}
// 自动添加 error 字段
if _, exists := attributes["error"]; !exists {
attributes["error"] = err.Error()
}
// 自动生成日志消息:使用 err.Error() 作为日志消息
logMessage := err.Error()
// 如果错误信息太长,截取前100个字符
if len(logMessage) > 100 {
logMessage = logMessage[:100] + "..."
}
// 自动推断 messageKey:根据 module 生成通用的错误消息键
messageKey := "operation_failed"
if module != "" {
// 尝试使用 module 相关的错误消息键
// 例如 "operation-log" -> "operation_failed"
// 或者保持通用
messageKey = "operation_failed"
}
// 系统级错误(500)自动记录日志
SetErrorLog(ctx, module, logMessage, attributes)
return Error(ctx, http.StatusInternalServerError, messageKey)
}
// ValidationError 验证错误响应(支持多语言,自动包含 trace_id 和 error_code
// 自动提取第一个错误信息并添加到 message 中,方便前端直接显示
func ValidationError(ctx http.Context, code int, messageKey string, errors map[string]map[string]string) http.Response {
baseMessage := trans.Get(ctx, messageKey)
// 提取第一个错误信息
var firstError string
for _, fieldErrors := range errors {
for _, errorMsg := range fieldErrors {
firstError = errorMsg
break
}
if firstError != "" {
break
}
}
// 如果有具体错误信息,将其添加到 message 中
var message string
if firstError != "" {
message = firstError
} else {
message = baseMessage
}
response := http.Json{
"code": code,
"message": message,
"error_code": messageKey, // 添加错误码字段,方便前端判断
"errors": errors,
}
// 自动包含 trace_id,方便前端显示和用户报告错误
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
response["trace_id"] = traceID
}
return ctx.Response().Json(code, response)
}
// Paginate 分页响应(支持多语言,自动包含 trace_id)
// messageKey 可选,如果不传则使用默认值 "get_success"
// 使用方式:
// - response.Paginate(ctx, list, total, page, pageSize)
// - response.Paginate(ctx, "custom_message", list, total, page, pageSize)
func Paginate(ctx http.Context, args ...any) http.Response {
var messageKey string
var list any
var total int64
var page, pageSize int
// 智能识别参数:如果第一个参数是 string 且长度合理(<=50),则认为是 messageKey
if len(args) >= 4 {
if msgKey, ok := args[0].(string); ok && len(msgKey) <= 50 {
// 方式1:传了 messageKey
messageKey = msgKey
list = args[1]
if t, ok := args[2].(int64); ok {
total = t
} else if t, ok := args[2].(int); ok {
total = int64(t)
}
if p, ok := args[3].(int); ok {
page = p
}
if len(args) >= 5 {
if ps, ok := args[4].(int); ok {
pageSize = ps
}
}
} else {
// 方式2:没传 messageKey
messageKey = "get_success" // 默认值
list = args[0]
if t, ok := args[1].(int64); ok {
total = t
} else if t, ok := args[1].(int); ok {
total = int64(t)
}
if p, ok := args[2].(int); ok {
page = p
}
if len(args) >= 4 {
if ps, ok := args[3].(int); ok {
pageSize = ps
}
}
}
}
// 如果 messageKey 为空,使用默认值
if messageKey == "" {
messageKey = "get_success"
}
message := trans.Get(ctx, messageKey)
// 转换列表中的时间字段到对应时区
convertedList := helpers.ConvertTimesInData(ctx, list)
response := http.Json{
"code": 200,
"message": message,
"data": http.Json{
"list": convertedList,
"total": total,
"page": page,
"page_size": pageSize,
},
}
// 自动包含 trace_id,方便前端追踪
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
response["trace_id"] = traceID
}
return ctx.Response().Success().Json(response)
}
// PaginateQueryOptions 分页查询选项
type PaginateQueryOptions struct {
// WithRelations 预加载关联,例如 []string{"Department", "Roles"}
WithRelations []string
// Transform 数据转换函数,可以对查询结果进行转换
Transform func(any) any
// ErrorHandler 自定义错误处理函数,如果为 nil 则使用默认错误处理
ErrorHandler func(ctx http.Context, err error, module string) http.Response
// ErrorModule 错误日志模块名,用于 ErrorWithLog
ErrorModule string
}
// PaginateQuery 通用的分页查询封装
// query: 构建好的查询对象(已包含所有查询条件)
// list: 用于接收查询结果的切片指针,例如 &[]models.Dictionary{}
// options: 可选配置
//
// 使用示例:
//
// - 基础用法:
// return response.PaginateQuery(ctx, query, &dictionaries, nil)
//
// - 带预加载关联:
// return response.PaginateQuery(ctx, query, &roles, &response.PaginateQueryOptions{
// WithRelations: []string{"Permissions", "Menus"},
// })
//
// - 带数据转换:
// return response.PaginateQuery(ctx, query, &admins, &response.PaginateQueryOptions{
// WithRelations: []string{"Department", "Roles"},
// Transform: func(data any) any {
// admins := data.(*[]models.Admin)
// // 转换逻辑
// return adminList
// },
// })
//
// - 带错误日志模块:
// return response.PaginateQuery(ctx, query, &logs, &response.PaginateQueryOptions{
// WithRelations: []string{"Admin"},
// ErrorModule: "login-log",
// })
func PaginateQuery(ctx http.Context, query orm.Query, list any, options *PaginateQueryOptions) http.Response {
// 获取分页参数
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
page, pageSize = helpers.ValidatePagination(page, pageSize)
// 获取总数
total, err := query.Count()
if err != nil {
if options != nil && options.ErrorHandler != nil {
return options.ErrorHandler(ctx, err, options.ErrorModule)
}
if options != nil && options.ErrorModule != "" {
return ErrorWithLog(ctx, options.ErrorModule, err)
}
return Error(ctx, http.StatusInternalServerError, "query_failed")
}
// 应用预加载关联
if options != nil && len(options.WithRelations) > 0 {
for _, relation := range options.WithRelations {
query = query.With(relation)
}
}
// 分页查询
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Get(list); err != nil {
if options != nil && options.ErrorHandler != nil {
return options.ErrorHandler(ctx, err, options.ErrorModule)
}
if options != nil && options.ErrorModule != "" {
return ErrorWithLog(ctx, options.ErrorModule, err)
}
return Error(ctx, http.StatusInternalServerError, "query_failed")
}
// 数据转换
var result any = list
if options != nil && options.Transform != nil {
result = options.Transform(list)
}
return Paginate(ctx, result, total, page, pageSize)
}
// Export 导出响应(支持多语言)
// headers: CSV表头(可以是翻译键数组或字符串数组)
// data: 数据行,每行是一个字符串切片
// filename: 文件名(不含扩展名)
func Export(ctx http.Context, messageKey string, headers []string, data [][]string, filename string) http.Response {
message := trans.Get(ctx, messageKey)
// 翻译表头(如果表头是翻译键,则翻译;如果是普通字符串,则保持原样)
translatedHeaders := make([]string, len(headers))
for i, header := range headers {
// 尝试翻译,如果翻译键不存在则返回原字符串
translated := trans.Get(ctx, header)
if translated == header {
// 如果翻译结果和原字符串相同,说明不是翻译键,直接使用原字符串
translatedHeaders[i] = header
} else {
// 如果翻译成功,使用翻译后的文本
translatedHeaders[i] = translated
}
}
exportService := services.NewExportService(ctx)
filePath, err := exportService.ExportToFile(translatedHeaders, data, filename)
if err != nil {
return Error(ctx, http.StatusInternalServerError, "export_failed")
}
exportURL := exportService.GetExportURL(filePath)
response := http.Json{
"code": 200,
"message": message,
"data": http.Json{
"file_path": filePath,
"file_url": exportURL,
},
}
// 自动包含 trace_id,方便前端追踪
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
response["trace_id"] = traceID
}
return ctx.Response().Success().Json(response)
}
// FindByIDOptions 查找选项
type FindByIDOptions struct {
// WithRelations 预加载关联,例如 []string{"Department", "Roles"}
WithRelations []string
// NotFoundMessageKey 未找到时的错误消息键,例如 "admin_not_found"
// 如果不提供,默认使用 "record_not_found"
NotFoundMessageKey string
}
// FindByID 通用的根据ID查找记录函数
// T 必须是嵌入了 orm.Model 的模型类型
// 使用示例:
//
// // 简单查找
// admin, resp := response.FindByID[models.Admin](ctx, id, nil)
// if resp != nil {
// return resp
// }
//
// // 带关联预加载
// admin, resp := response.FindByID[models.Admin](ctx, id, &response.FindByIDOptions{
// WithRelations: []string{"Department", "Roles"},
// NotFoundMessageKey: "admin_not_found",
// })
// if resp != nil {
// return resp
// }
func FindByID[T any](ctx http.Context, id uint, options *FindByIDOptions) (*T, http.Response) {
// 验证ID
if id == 0 {
return nil, Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
// 创建查询
query := facades.Orm().Query().Where("id", id)
// 应用关联预加载
if options != nil && len(options.WithRelations) > 0 {
for _, relation := range options.WithRelations {
query = query.With(relation)
}
}
// 查询记录
var model T
if err := query.First(&model); err != nil {
// 确定错误消息键,默认使用 record_not_found
messageKey := apperrors.ErrRecordNotFound.Code
if options != nil && options.NotFoundMessageKey != "" {
messageKey = options.NotFoundMessageKey
}
return nil, Error(ctx, http.StatusNotFound, messageKey)
}
// 检查记录是否存在(防御性编程)
// 通过反射检查 ID 字段,如果 ID 为 0,说明记录不存在
modelPtr := &model
if !hasValidID(modelPtr) {
// 确定错误消息键,默认使用 record_not_found
messageKey := apperrors.ErrRecordNotFound.Code
if options != nil && options.NotFoundMessageKey != "" {
messageKey = options.NotFoundMessageKey
}
return nil, Error(ctx, http.StatusNotFound, messageKey)
}
return modelPtr, nil
}
// hasValidID 检查模型是否有有效的 IDID != 0)
// 使用反射来检查模型的 ID 字段
func hasValidID(model any) bool {
v := reflect.ValueOf(model)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
// 查找 ID 字段
idField := v.FieldByName("ID")
if !idField.IsValid() {
// 如果没有找到 ID 字段,假设记录有效(防御性编程)
return true
}
// 检查 ID 是否为 0
if idField.Kind() == reflect.Uint || idField.Kind() == reflect.Uint32 || idField.Kind() == reflect.Uint64 {
return idField.Uint() != 0
}
return true
}
+38
View File
@@ -0,0 +1,38 @@
package trans
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
// Get 获取翻译文本(支持多语言)
// 自动尝试 messages. 前缀的翻译键
func Get(ctx http.Context, key string) string {
// 如果key已经包含 messages. 前缀,直接使用
if len(key) > 8 && key[:8] == "messages." {
message := facades.Lang(ctx).Get(key)
// 如果返回的键和输入的键相同或为空,说明没找到
if message != key && message != "" {
return message
}
return key
}
// 尝试使用 messages. 前缀的key(这是语言文件中的实际格式)
messageKey := "messages." + key
message := facades.Lang(ctx).Get(messageKey)
// 如果返回的键和输入的键不同且不为空,说明找到了
if message != messageKey && message != "" {
return message
}
// 如果带前缀的找不到,尝试直接获取(某些键可能不在messages下)
message = facades.Lang(ctx).Get(key)
if message != key && message != "" {
return message
}
// 如果还是不存在,返回原始key
return key
}