init
This commit is contained in:
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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(®isterRequest)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user