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
}