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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
|
||||
"goravel/app/models"
|
||||
)
|
||||
|
||||
// GetAdminFromContext 从 context 中获取 admin 对象
|
||||
// 如果 context 中没有 admin 或类型不匹配,返回错误
|
||||
func GetAdminFromContext(ctx http.Context) (*models.Admin, error) {
|
||||
adminValue := ctx.Value("admin")
|
||||
if adminValue == nil {
|
||||
return nil, errors.New("admin not found in context")
|
||||
}
|
||||
|
||||
// 尝试值类型
|
||||
if admin, ok := adminValue.(models.Admin); ok {
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
// 尝试指针类型
|
||||
if adminPtr, ok := adminValue.(*models.Admin); ok {
|
||||
return adminPtr, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid admin type in context")
|
||||
}
|
||||
|
||||
// GetAdminIDFromContext 从 context 中获取 admin ID
|
||||
// 如果 context 中没有 admin 或类型不匹配,返回 0 和错误
|
||||
func GetAdminIDFromContext(ctx http.Context) (uint, error) {
|
||||
admin, err := GetAdminFromContext(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return admin.ID, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/support/str"
|
||||
)
|
||||
|
||||
// GetRealIP 获取客户端真实IP地址
|
||||
// 优先从以下HTTP头获取(按顺序):
|
||||
// 1. CF-Connecting-IP (Cloudflare)
|
||||
// 2. True-Client-IP
|
||||
// 3. X-Real-IP
|
||||
// 4. X-Forwarded-For (取第一个IP)
|
||||
// 5. RemoteAddr
|
||||
func GetRealIP(ctx http.Context) string {
|
||||
// 1. Cloudflare
|
||||
if ip := ctx.Request().Header("CF-Connecting-IP", ""); ip != "" {
|
||||
if parsedIP := parseIP(ip); parsedIP != "" {
|
||||
return parsedIP
|
||||
}
|
||||
}
|
||||
|
||||
// 2. True-Client-IP
|
||||
if ip := ctx.Request().Header("True-Client-IP", ""); ip != "" {
|
||||
if parsedIP := parseIP(ip); parsedIP != "" {
|
||||
return parsedIP
|
||||
}
|
||||
}
|
||||
|
||||
// 3. X-Real-IP
|
||||
if ip := ctx.Request().Header("X-Real-IP", ""); ip != "" {
|
||||
if parsedIP := parseIP(ip); parsedIP != "" {
|
||||
return parsedIP
|
||||
}
|
||||
}
|
||||
|
||||
// 4. X-Forwarded-For (可能包含多个IP,取第一个)
|
||||
if forwardedFor := ctx.Request().Header("X-Forwarded-For", ""); !str.Of(forwardedFor).IsEmpty() {
|
||||
ips := str.Of(forwardedFor).Split(",")
|
||||
if len(ips) > 0 {
|
||||
ip := str.Of(ips[0]).Trim().String()
|
||||
if parsedIP := parseIP(ip); !str.Of(parsedIP).IsEmpty() {
|
||||
return parsedIP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. RemoteAddr
|
||||
remoteAddr := ctx.Request().Ip()
|
||||
if parsedIP := parseIP(remoteAddr); parsedIP != "" {
|
||||
return parsedIP
|
||||
}
|
||||
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
// parseIP 解析并验证IP地址
|
||||
func parseIP(ip string) string {
|
||||
ip = str.Of(ip).Trim().String()
|
||||
if str.Of(ip).IsEmpty() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 如果包含端口,去掉端口
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil {
|
||||
ip = host
|
||||
}
|
||||
|
||||
// 验证是否为有效IP
|
||||
parsedIP := net.ParseIP(ip)
|
||||
if parsedIP == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package helpers
|
||||
|
||||
// 注意:FindByID 函数已移动到 response 包中,以避免循环导入
|
||||
// 请使用 response.FindByID 代替 helpers.FindByID
|
||||
// 相关的 FindByIDOptions 类型也在 response 包中
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package helpers
|
||||
|
||||
// PaginateSlice 对切片进行分页处理
|
||||
// 返回分页后的切片和总数
|
||||
func PaginateSlice[T any](slice []T, page, pageSize int) ([]T, int64) {
|
||||
total := int64(len(slice))
|
||||
if total == 0 {
|
||||
return []T{}, 0
|
||||
}
|
||||
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
|
||||
// 如果起始位置超出范围,返回空切片
|
||||
if start >= len(slice) {
|
||||
return []T{}, total
|
||||
}
|
||||
|
||||
// 如果结束位置超出范围,截取到末尾
|
||||
if end > len(slice) {
|
||||
end = len(slice)
|
||||
}
|
||||
|
||||
return slice[start:end], total
|
||||
}
|
||||
|
||||
// ValidatePagination 验证并规范化分页参数
|
||||
// 返回规范化后的 page 和 pageSize
|
||||
func ValidatePagination(page, pageSize int) (int, int) {
|
||||
// 默认值
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
// 最大限制
|
||||
const maxPageSize = 100
|
||||
if pageSize > maxPageSize {
|
||||
pageSize = maxPageSize
|
||||
}
|
||||
|
||||
return page, pageSize
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/database/orm"
|
||||
"github.com/goravel/framework/support/str"
|
||||
)
|
||||
|
||||
// ApplySort 应用排序到查询
|
||||
// orderBy: 排序参数,格式为 "field:direction" 或 "field1:direction1,field2:direction2"
|
||||
// defaultSort: 默认排序,格式为 "field:direction",如果 orderBy 为空则使用此默认值
|
||||
// 返回: 应用了排序的查询对象
|
||||
func ApplySort(query orm.Query, orderBy string, defaultSort string) orm.Query {
|
||||
// 如果提供了排序参数,使用它;否则使用默认排序
|
||||
sortStr := orderBy
|
||||
if sortStr == "" {
|
||||
sortStr = defaultSort
|
||||
}
|
||||
|
||||
// 如果排序字符串为空,返回原查询
|
||||
if sortStr == "" {
|
||||
return query
|
||||
}
|
||||
|
||||
// 解析多个排序字段(逗号分隔)
|
||||
sortFields := str.Of(sortStr).Split(",")
|
||||
var orderClauses []string
|
||||
|
||||
for _, field := range sortFields {
|
||||
field = str.Of(field).Trim().String()
|
||||
if str.Of(field).IsEmpty() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析字段和方向(格式: "field:direction" 或 "field")
|
||||
parts := str.Of(field).Split(":")
|
||||
fieldName := str.Of(parts[0]).Trim().String()
|
||||
direction := "asc" // 默认升序
|
||||
|
||||
if len(parts) > 1 {
|
||||
direction = str.Of(parts[1]).Trim().Lower().String()
|
||||
}
|
||||
|
||||
// 验证方向
|
||||
if direction != "asc" && direction != "desc" {
|
||||
direction = "asc"
|
||||
}
|
||||
|
||||
// 收集排序子句
|
||||
orderClauses = append(orderClauses, fieldName+" "+direction)
|
||||
}
|
||||
|
||||
// 如果有排序子句,组合成一个字符串并应用
|
||||
if len(orderClauses) > 0 {
|
||||
var orderStr string
|
||||
if len(orderClauses) > 0 {
|
||||
orderStr = orderClauses[0]
|
||||
for i := 1; i < len(orderClauses); i++ {
|
||||
orderStr = str.Of(orderStr).Append(", ").Append(orderClauses[i]).String()
|
||||
}
|
||||
}
|
||||
query = query.Order(orderStr)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// ParseSort 解析排序参数
|
||||
// orderBy: 排序参数,格式为 "field:direction" 或 "field1:direction1,field2:direction2"
|
||||
// 返回: 排序字段和方向的映射
|
||||
func ParseSort(orderBy string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
if orderBy == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
// 解析多个排序字段(逗号分隔)
|
||||
sortFields := str.Of(orderBy).Split(",")
|
||||
|
||||
for _, field := range sortFields {
|
||||
field = str.Of(field).Trim().String()
|
||||
if str.Of(field).IsEmpty() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析字段和方向
|
||||
parts := str.Of(field).Split(":")
|
||||
fieldName := str.Of(parts[0]).Trim().String()
|
||||
direction := "asc" // 默认升序
|
||||
|
||||
if len(parts) > 1 {
|
||||
direction = str.Of(parts[1]).Trim().Lower().String()
|
||||
}
|
||||
|
||||
// 验证方向
|
||||
if direction != "asc" && direction != "desc" {
|
||||
direction = "asc"
|
||||
}
|
||||
|
||||
result[fieldName] = direction
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"goravel/app/utils"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/support/carbon"
|
||||
)
|
||||
|
||||
// ConvertTimesInData 递归转换数据中的时间字段到对应时区
|
||||
// 使用 JSON 序列化和反序列化来确保正确处理所有类型
|
||||
func ConvertTimesInData(ctx http.Context, data any) any {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取请求的时区
|
||||
timezone := GetCurrentTimezone(ctx)
|
||||
|
||||
// 检查是否传了时区请求头
|
||||
hasTimezoneHeader := ctx.Request().Header("X-Timezone", "") != "" ||
|
||||
ctx.Request().Header("Timezone", "") != "" ||
|
||||
ctx.Request().Input("timezone") != ""
|
||||
|
||||
// 如果没有传时区请求头,且时区是 UTC,直接返回原数据(不做转换)
|
||||
if !hasTimezoneHeader && (timezone == carbon.UTC || timezone == "UTC") {
|
||||
return data
|
||||
}
|
||||
|
||||
// 先序列化为 JSON
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
// 如果序列化失败,尝试使用反射方法
|
||||
return convertTimesInValue(reflect.ValueOf(data), timezone)
|
||||
}
|
||||
|
||||
// 反序列化为 map[string]any
|
||||
var result any
|
||||
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||
// 如果反序列化失败,返回原数据
|
||||
return data
|
||||
}
|
||||
|
||||
// 转换时间字段
|
||||
converted := convertTimesInMap(result, timezone)
|
||||
|
||||
return converted
|
||||
}
|
||||
|
||||
// convertTimesInMap 递归处理 map 或 slice 中的时间字段
|
||||
func convertTimesInMap(data any, timezone string) any {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any)
|
||||
for key, value := range v {
|
||||
// 检查是否是时间字段
|
||||
if isTimeField(key) {
|
||||
// 尝试解析时间字符串并转换
|
||||
if timeStr, ok := value.(string); ok && timeStr != "" {
|
||||
// 如果时区是 UTC,直接返回原时间字符串(不做转换)
|
||||
if timezone == carbon.UTC || timezone == "UTC" {
|
||||
result[key] = timeStr
|
||||
continue
|
||||
}
|
||||
// 否则进行时区转换
|
||||
converted := convertTimeString(timeStr, timezone)
|
||||
if converted != nil && converted != "" {
|
||||
result[key] = converted
|
||||
continue
|
||||
}
|
||||
// 如果转换失败,保留原值
|
||||
result[key] = timeStr
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 递归处理嵌套数据
|
||||
result[key] = convertTimesInMap(value, timezone)
|
||||
}
|
||||
return result
|
||||
|
||||
case []any:
|
||||
result := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
result[i] = convertTimesInMap(item, timezone)
|
||||
}
|
||||
return result
|
||||
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// convertTimeString 转换时间字符串到指定时区
|
||||
// 假设数据库存储的时间是 UTC 时区(如:2025-11-22 06:21:25)
|
||||
func convertTimeString(timeStr string, timezone string) any {
|
||||
if timeStr == "" || timeStr == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果目标时区是 UTC,直接返回原时间字符串
|
||||
if timezone == carbon.UTC || timezone == "UTC" {
|
||||
return timeStr
|
||||
}
|
||||
|
||||
// 加载时区
|
||||
utcLoc, _ := time.LoadLocation("UTC")
|
||||
targetLoc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return timeStr
|
||||
}
|
||||
|
||||
// 解析时间字符串为 UTC(数据库存储格式)
|
||||
t, err := time.ParseInLocation(utils.DateTimeFormat, timeStr, utcLoc)
|
||||
if err != nil {
|
||||
// 如果标准格式失败,尝试其他格式
|
||||
t, err = time.Parse(time.RFC3339, timeStr)
|
||||
if err != nil {
|
||||
return timeStr
|
||||
}
|
||||
// RFC3339 格式可能带时区,转换为 UTC
|
||||
t = time.Unix(t.Unix(), 0).In(utcLoc)
|
||||
}
|
||||
|
||||
// 转换到目标时区并格式化
|
||||
return t.In(targetLoc).Format(utils.DateTimeFormat)
|
||||
}
|
||||
|
||||
// convertTimesInValue 使用反射方法处理值(作为备用方案)
|
||||
func convertTimesInValue(v reflect.Value, timezone string) any {
|
||||
if !v.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 处理指针
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
return convertTimesInValue(v.Elem(), timezone)
|
||||
}
|
||||
|
||||
// 处理时间类型
|
||||
if v.Type() == reflect.TypeOf((*carbon.DateTime)(nil)).Elem() {
|
||||
dt := v.Interface().(carbon.DateTime)
|
||||
return dt.SetTimezone(timezone).ToDateTimeString()
|
||||
}
|
||||
|
||||
// 处理 *carbon.DateTime
|
||||
if v.Type() == reflect.TypeOf((*carbon.DateTime)(nil)) {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
dt := v.Interface().(*carbon.DateTime)
|
||||
if dt == nil {
|
||||
return nil
|
||||
}
|
||||
return dt.SetTimezone(timezone).ToDateTimeString()
|
||||
}
|
||||
|
||||
// 处理 time.Time
|
||||
if v.Type() == reflect.TypeOf(time.Time{}) {
|
||||
t := v.Interface().(time.Time)
|
||||
dt := carbon.NewDateTime(carbon.Parse(t.Format(utils.DateTimeFormat)))
|
||||
return dt.SetTimezone(timezone).ToDateTimeString()
|
||||
}
|
||||
|
||||
// 处理 *time.Time
|
||||
if v.Type() == reflect.TypeOf((*time.Time)(nil)) {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
t := v.Interface().(*time.Time)
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
dt := carbon.NewDateTime(carbon.Parse(t.Format(utils.DateTimeFormat)))
|
||||
return dt.SetTimezone(timezone).ToDateTimeString()
|
||||
}
|
||||
|
||||
// 处理切片
|
||||
if v.Kind() == reflect.Slice {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
result := make([]any, v.Len())
|
||||
for i := range result {
|
||||
result[i] = convertTimesInValue(v.Index(i), timezone)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 处理数组
|
||||
if v.Kind() == reflect.Array {
|
||||
result := make([]any, v.Len())
|
||||
for i := range result {
|
||||
result[i] = convertTimesInValue(v.Index(i), timezone)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 处理 map
|
||||
if v.Kind() == reflect.Map {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]any)
|
||||
for _, key := range v.MapKeys() {
|
||||
keyStr := key.String()
|
||||
if key.Kind() == reflect.Interface {
|
||||
keyStr = reflect.ValueOf(key.Interface()).String()
|
||||
}
|
||||
result[keyStr] = convertTimesInValue(v.MapIndex(key), timezone)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 处理结构体
|
||||
if v.Kind() == reflect.Struct {
|
||||
result := make(map[string]any)
|
||||
t := v.Type()
|
||||
for i := range make([]int, v.NumField()) {
|
||||
field := t.Field(i)
|
||||
fieldValue := v.Field(i)
|
||||
|
||||
// 跳过未导出字段
|
||||
if !fieldValue.CanInterface() {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := field.Name
|
||||
// 检查 json tag
|
||||
if jsonTag := field.Tag.Get("json"); jsonTag != "" && jsonTag != "-" {
|
||||
// 解析 json tag(处理 "name,omitempty" 格式)
|
||||
parts := strings.Split(jsonTag, ",")
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
fieldName = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 只处理时间相关字段
|
||||
if isTimeField(fieldName) || isTimeType(fieldValue.Type()) {
|
||||
result[fieldName] = convertTimesInValue(fieldValue, timezone)
|
||||
} else {
|
||||
// 递归处理嵌套结构
|
||||
if fieldValue.Kind() == reflect.Struct || fieldValue.Kind() == reflect.Ptr || fieldValue.Kind() == reflect.Slice || fieldValue.Kind() == reflect.Map {
|
||||
result[fieldName] = convertTimesInValue(fieldValue, timezone)
|
||||
} else {
|
||||
result[fieldName] = fieldValue.Interface()
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 其他类型直接返回
|
||||
return v.Interface()
|
||||
}
|
||||
|
||||
// isTimeField 检查字段名是否是时间字段
|
||||
func isTimeField(fieldName string) bool {
|
||||
return fieldName == "created_at" || fieldName == "updated_at" || fieldName == "deleted_at" ||
|
||||
fieldName == "CreatedAt" || fieldName == "UpdatedAt" || fieldName == "DeletedAt"
|
||||
}
|
||||
|
||||
// isTimeType 检查类型是否是时间类型
|
||||
func isTimeType(t reflect.Type) bool {
|
||||
if t == reflect.TypeOf((*carbon.DateTime)(nil)).Elem() ||
|
||||
t == reflect.TypeOf((*carbon.DateTime)(nil)) ||
|
||||
t == reflect.TypeOf(time.Time{}) ||
|
||||
t == reflect.TypeOf((*time.Time)(nil)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"goravel/app/utils"
|
||||
"time"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
"github.com/goravel/framework/support/carbon"
|
||||
"github.com/goravel/framework/support/str"
|
||||
)
|
||||
|
||||
// GetCurrentTimezone 获取当前请求的时区
|
||||
// 优先从请求头 X-Timezone 或 Timezone 获取
|
||||
// 如果请求头没有或时区无效,使用配置的默认时区
|
||||
func GetCurrentTimezone(ctx http.Context) string {
|
||||
// 优先从 X-Timezone 请求头获取
|
||||
timezone := ctx.Request().Header("X-Timezone", "")
|
||||
if timezone == "" {
|
||||
// 尝试从 Timezone 请求头获取
|
||||
timezone = ctx.Request().Header("Timezone", "")
|
||||
}
|
||||
if timezone == "" {
|
||||
// 尝试从查询参数获取
|
||||
timezone = ctx.Request().Input("timezone")
|
||||
}
|
||||
|
||||
// 如果从请求中获取到了时区,规范化并返回
|
||||
if timezone != "" {
|
||||
return NormalizeTimezone(timezone)
|
||||
}
|
||||
|
||||
// 如果都没有,使用配置的默认时区
|
||||
defaultTimezone := facades.Config().GetString("app.timezone", carbon.UTC)
|
||||
return NormalizeTimezone(defaultTimezone)
|
||||
}
|
||||
|
||||
// isValidTimezone 验证时区是否有效
|
||||
func isValidTimezone(timezone string) bool {
|
||||
if timezone == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 尝试加载时区
|
||||
_, err := time.LoadLocation(timezone)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// NormalizeTimezone 规范化时区名称(处理常见别名)
|
||||
func NormalizeTimezone(timezone string) string {
|
||||
timezone = str.Of(timezone).Trim().String()
|
||||
if str.Of(timezone).IsEmpty() {
|
||||
return carbon.UTC
|
||||
}
|
||||
|
||||
// 转换为标准时区名称
|
||||
timezoneMap := map[string]string{
|
||||
"UTC": "UTC",
|
||||
"GMT": "UTC",
|
||||
"PST": "America/Los_Angeles",
|
||||
"PDT": "America/Los_Angeles",
|
||||
"EST": "America/New_York",
|
||||
"EDT": "America/New_York",
|
||||
"CST": "America/Chicago",
|
||||
"CDT": "America/Chicago",
|
||||
"MST": "America/Denver",
|
||||
"MDT": "America/Denver",
|
||||
"Beijing": "Asia/Shanghai",
|
||||
"Shanghai": "Asia/Shanghai",
|
||||
"Hong Kong": "Asia/Hong_Kong",
|
||||
"Tokyo": "Asia/Tokyo",
|
||||
"Seoul": "Asia/Seoul",
|
||||
"Singapore": "Asia/Singapore",
|
||||
"London": "Europe/London",
|
||||
"Paris": "Europe/Paris",
|
||||
"Berlin": "Europe/Berlin",
|
||||
"Moscow": "Europe/Moscow",
|
||||
"Sydney": "Australia/Sydney",
|
||||
"Melbourne": "Australia/Melbourne",
|
||||
}
|
||||
|
||||
if normalized, ok := timezoneMap[timezone]; ok {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// 如果时区有效,直接返回
|
||||
if isValidTimezone(timezone) {
|
||||
return timezone
|
||||
}
|
||||
|
||||
// 默认返回 UTC
|
||||
return carbon.UTC
|
||||
}
|
||||
|
||||
// ConvertTimeToTimezone 将时间字符串转换为指定时区
|
||||
// 返回转换后的时间字符串
|
||||
func ConvertTimeToTimezone(timeStr string, timezone string) string {
|
||||
if timeStr == "" {
|
||||
return ""
|
||||
}
|
||||
// 规范化时区
|
||||
timezone = NormalizeTimezone(timezone)
|
||||
|
||||
// 解析时间字符串
|
||||
dt := carbon.Parse(timeStr)
|
||||
if dt.IsZero() {
|
||||
return timeStr
|
||||
}
|
||||
// 转换时区并返回格式化的字符串
|
||||
return dt.SetTimezone(timezone).ToDateTimeString()
|
||||
}
|
||||
|
||||
// ConvertTimeByContext 根据请求头中的时区转换时间字符串
|
||||
// 返回转换后的时间字符串
|
||||
func ConvertTimeByContext(ctx http.Context, timeStr string) string {
|
||||
if timeStr == "" {
|
||||
return ""
|
||||
}
|
||||
timezone := GetCurrentTimezone(ctx)
|
||||
return ConvertTimeToTimezone(timeStr, timezone)
|
||||
}
|
||||
|
||||
// ConvertTimeToUTC 将本地时区的时间字符串转换为 UTC 时间字符串(用于数据库查询)
|
||||
// timeStr: 前端传入的时间字符串(本地时区格式,如 "2025-11-25 14:00:00")
|
||||
// ctx: 请求上下文,用于获取当前时区
|
||||
// 返回: UTC 时间字符串(如 "2025-11-25 06:00:00")
|
||||
func ConvertTimeToUTC(ctx http.Context, timeStr string) string {
|
||||
if timeStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 获取当前请求的时区
|
||||
timezone := GetCurrentTimezone(ctx)
|
||||
|
||||
// 如果已经是 UTC,直接返回
|
||||
if timezone == carbon.UTC || timezone == "UTC" {
|
||||
return timeStr
|
||||
}
|
||||
|
||||
// 加载时区
|
||||
targetLoc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
// 如果时区无效,假设是 UTC
|
||||
return timeStr
|
||||
}
|
||||
utcLoc, _ := time.LoadLocation("UTC")
|
||||
|
||||
// 解析时间字符串(假设是本地时区格式)
|
||||
// 尝试多种格式
|
||||
formats := []string{
|
||||
utils.DateTimeFormat,
|
||||
utils.DateTimeFormatT,
|
||||
utils.DateTimeFormatMs,
|
||||
utils.DateTimeFormatTZ,
|
||||
time.RFC3339,
|
||||
}
|
||||
|
||||
var t time.Time
|
||||
var parseErr error
|
||||
for _, format := range formats {
|
||||
t, parseErr = time.ParseInLocation(format, timeStr, targetLoc)
|
||||
if parseErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parseErr != nil {
|
||||
// 如果所有格式都失败,尝试使用 carbon 解析
|
||||
dt := carbon.Parse(timeStr)
|
||||
if dt.IsZero() {
|
||||
return timeStr
|
||||
}
|
||||
// 假设解析的时间是本地时区,转换为 UTC
|
||||
return dt.SetTimezone(carbon.UTC).ToDateTimeString()
|
||||
}
|
||||
|
||||
// 转换为 UTC 并格式化
|
||||
return t.In(utcLoc).Format(utils.DateTimeFormat)
|
||||
}
|
||||
|
||||
// GetTimeQueryParam 获取并转换时间查询参数(统一处理时间查询)
|
||||
// 自动将前端传入的本地时区时间转换为 UTC 时间用于数据库查询
|
||||
// 支持常见的时间查询参数名称:start_time, end_time, created_at_start, created_at_end, updated_at_start, updated_at_end
|
||||
func GetTimeQueryParam(ctx http.Context, paramName string) string {
|
||||
timeStr := ctx.Request().Query(paramName, "")
|
||||
if timeStr == "" {
|
||||
return ""
|
||||
}
|
||||
return ConvertTimeToUTC(ctx, timeStr)
|
||||
}
|
||||
|
||||
// FormatTimeWithTimezone 使用指定时区格式化 time.Time
|
||||
func FormatTimeWithTimezone(t time.Time, timezone string) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
}
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return t.In(loc).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// FormatCarbonWithTimezone 使用指定时区格式化 Carbon 时间
|
||||
func FormatCarbonWithTimezone(t *carbon.DateTime, timezone string) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return FormatTimeWithTimezone(t.StdTime(), timezone)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/support/str"
|
||||
|
||||
"goravel/app/models"
|
||||
)
|
||||
|
||||
// GetUserFromContext 从 context 中获取 user 对象
|
||||
// 如果 context 中没有 user 或类型不匹配,返回错误
|
||||
func GetUserFromContext(ctx http.Context) (*models.User, error) {
|
||||
userValue := ctx.Value("user")
|
||||
if userValue == nil {
|
||||
return nil, errors.New("user not found in context")
|
||||
}
|
||||
|
||||
// 尝试值类型
|
||||
if user, ok := userValue.(models.User); ok {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// 尝试指针类型
|
||||
if userPtr, ok := userValue.(*models.User); ok {
|
||||
return userPtr, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid user type in context")
|
||||
}
|
||||
|
||||
// GetUserIDFromContext 从 context 中获取 user ID
|
||||
// 如果 context 中没有 user 或类型不匹配,返回 0 和错误
|
||||
func GetUserIDFromContext(ctx http.Context) (uint, error) {
|
||||
user, err := GetUserFromContext(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
// GetTokenFromHeader 从请求头中获取token
|
||||
// 支持从Authorization header或URL参数中获取
|
||||
func GetTokenFromHeader(ctx http.Context) string {
|
||||
token := ctx.Request().Header("Authorization", "")
|
||||
|
||||
// 如果 Header 中没有 token,尝试从 URL 参数中获取
|
||||
if str.Of(token).IsEmpty() {
|
||||
token = ctx.Request().Query("_token", "")
|
||||
}
|
||||
|
||||
// 移除Bearer前缀(如果有)
|
||||
token = str.Of(token).ChopStart("Bearer ").Trim().String()
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
// ParseUserAgent 解析User-Agent字符串,返回浏览器和操作系统信息
|
||||
func ParseUserAgent(userAgent string) (browser, os string) {
|
||||
if userAgent == "" {
|
||||
return "Unknown", "Unknown"
|
||||
}
|
||||
|
||||
ua := strings.ToLower(userAgent)
|
||||
|
||||
// 解析浏览器
|
||||
browser = parseBrowser(ua)
|
||||
|
||||
// 解析操作系统
|
||||
os = parseOS(ua)
|
||||
|
||||
return browser, os
|
||||
}
|
||||
|
||||
// parseBrowser 解析浏览器类型
|
||||
func parseBrowser(ua string) string {
|
||||
// Chrome
|
||||
if strings.Contains(ua, "chrome") && !strings.Contains(ua, "edg") && !strings.Contains(ua, "opr") {
|
||||
// 提取Chrome版本
|
||||
if idx := strings.Index(ua, "chrome/"); idx != -1 {
|
||||
version := extractVersion(ua, idx+7)
|
||||
return "Chrome " + version
|
||||
}
|
||||
return "Chrome"
|
||||
}
|
||||
|
||||
// Edge
|
||||
if strings.Contains(ua, "edg") {
|
||||
if idx := strings.Index(ua, "edg/"); idx != -1 {
|
||||
version := extractVersion(ua, idx+4)
|
||||
return "Edge " + version
|
||||
}
|
||||
return "Edge"
|
||||
}
|
||||
|
||||
// Firefox
|
||||
if strings.Contains(ua, "firefox") {
|
||||
if idx := strings.Index(ua, "firefox/"); idx != -1 {
|
||||
version := extractVersion(ua, idx+8)
|
||||
return "Firefox " + version
|
||||
}
|
||||
return "Firefox"
|
||||
}
|
||||
|
||||
// Safari
|
||||
if strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome") {
|
||||
if idx := strings.Index(ua, "version/"); idx != -1 {
|
||||
version := extractVersion(ua, idx+8)
|
||||
return "Safari " + version
|
||||
}
|
||||
return "Safari"
|
||||
}
|
||||
|
||||
// Opera
|
||||
if strings.Contains(ua, "opr") || strings.Contains(ua, "opera") {
|
||||
if idx := strings.Index(ua, "opr/"); idx != -1 {
|
||||
version := extractVersion(ua, idx+4)
|
||||
return "Opera " + version
|
||||
}
|
||||
if idx := strings.Index(ua, "version/"); idx != -1 {
|
||||
version := extractVersion(ua, idx+8)
|
||||
return "Opera " + version
|
||||
}
|
||||
return "Opera"
|
||||
}
|
||||
|
||||
// IE
|
||||
if strings.Contains(ua, "msie") || strings.Contains(ua, "trident") {
|
||||
if idx := strings.Index(ua, "msie "); idx != -1 {
|
||||
version := extractVersion(ua, idx+5)
|
||||
return "IE " + version
|
||||
}
|
||||
return "IE"
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// parseOS 解析操作系统
|
||||
func parseOS(ua string) string {
|
||||
// Windows
|
||||
if strings.Contains(ua, "windows") {
|
||||
if strings.Contains(ua, "windows nt 10.0") || strings.Contains(ua, "windows nt 6.3") {
|
||||
return "Windows 10/11"
|
||||
}
|
||||
if strings.Contains(ua, "windows nt 6.2") {
|
||||
return "Windows 8"
|
||||
}
|
||||
if strings.Contains(ua, "windows nt 6.1") {
|
||||
return "Windows 7"
|
||||
}
|
||||
if strings.Contains(ua, "windows nt 6.0") {
|
||||
return "Windows Vista"
|
||||
}
|
||||
if strings.Contains(ua, "windows nt 5.1") {
|
||||
return "Windows XP"
|
||||
}
|
||||
return "Windows"
|
||||
}
|
||||
|
||||
// macOS
|
||||
if strings.Contains(ua, "mac os x") || strings.Contains(ua, "macintosh") {
|
||||
if idx := strings.Index(ua, "mac os x "); idx != -1 {
|
||||
version := extractVersion(ua, idx+9)
|
||||
return "macOS " + version
|
||||
}
|
||||
return "macOS"
|
||||
}
|
||||
|
||||
// iOS
|
||||
if strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad") || strings.Contains(ua, "ipod") {
|
||||
if idx := strings.Index(ua, "os "); idx != -1 {
|
||||
version := extractVersion(ua, idx+3)
|
||||
version = strings.ReplaceAll(version, "_", ".")
|
||||
return "iOS " + version
|
||||
}
|
||||
return "iOS"
|
||||
}
|
||||
|
||||
// Android
|
||||
if strings.Contains(ua, "android") {
|
||||
if idx := strings.Index(ua, "android "); idx != -1 {
|
||||
version := extractVersion(ua, idx+8)
|
||||
return "Android " + version
|
||||
}
|
||||
return "Android"
|
||||
}
|
||||
|
||||
// Linux
|
||||
if strings.Contains(ua, "linux") {
|
||||
return "Linux"
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// extractVersion 从User-Agent字符串中提取版本号
|
||||
func extractVersion(ua string, startIdx int) string {
|
||||
if startIdx >= len(ua) {
|
||||
return ""
|
||||
}
|
||||
|
||||
var version strings.Builder
|
||||
for i := startIdx; i < len(ua); i++ {
|
||||
c := ua[i]
|
||||
if (c >= '0' && c <= '9') || c == '.' || c == '_' {
|
||||
version.WriteByte(c)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result := version.String()
|
||||
if len(result) > 10 {
|
||||
return result[:10]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetBrowserAndOS 从HTTP上下文获取浏览器和操作系统信息
|
||||
func GetBrowserAndOS(ctx http.Context) (browser, os string) {
|
||||
userAgent := ctx.Request().Header("User-Agent", "")
|
||||
return ParseUserAgent(userAgent)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
"github.com/goravel/framework/support/str"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// GetIntQuery 获取并验证整数查询参数
|
||||
// 如果参数无效或不存在,返回默认值
|
||||
func GetIntQuery(ctx http.Context, key string, defaultValue int) int {
|
||||
value := ctx.Request().Query(key, "")
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
result := cast.ToInt(value)
|
||||
if result < 1 {
|
||||
return defaultValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetUintQuery 获取并验证无符号整数查询参数
|
||||
// 如果参数无效或不存在,返回默认值
|
||||
func GetUintQuery(ctx http.Context, key string, defaultValue uint) uint {
|
||||
value := ctx.Request().Query(key, "")
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
result := cast.ToUint(value)
|
||||
if result == 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetUintRoute 获取并验证路由中的无符号整数参数
|
||||
// 如果参数无效或不存在,返回 0
|
||||
func GetUintRoute(ctx http.Context, key string) uint {
|
||||
value := ctx.Request().Route(key)
|
||||
return cast.ToUint(value)
|
||||
}
|
||||
|
||||
// ParseIDsFromString 从逗号分隔的字符串中解析 ID 列表
|
||||
// 返回去重后的 ID 列表
|
||||
func ParseIDsFromString(idStr string) []uint {
|
||||
if str.Of(idStr).IsEmpty() {
|
||||
return []uint{}
|
||||
}
|
||||
|
||||
var ids []uint
|
||||
idMap := make(map[uint]bool)
|
||||
|
||||
// 分割字符串
|
||||
idStrs := str.Of(idStr).Split(",")
|
||||
for _, idStr := range idStrs {
|
||||
idStr = str.Of(idStr).Trim().String()
|
||||
if str.Of(idStr).IsEmpty() {
|
||||
continue
|
||||
}
|
||||
|
||||
id := cast.ToUint(idStr)
|
||||
if id > 0 && !idMap[id] {
|
||||
idMap[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// ConvertUintSliceToAny 将 uint 切片转换为 []any
|
||||
// 用于 ORM 的 WhereIn 查询
|
||||
func ConvertUintSliceToAny(ids []uint) []any {
|
||||
if len(ids) == 0 {
|
||||
return []any{}
|
||||
}
|
||||
|
||||
result := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
result[i] = id
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// PrepareNumericFieldForValidation 在 PrepareForValidation 中准备数字字段
|
||||
// 将指定的数字字段转换为字符串,以便 in 规则能正确验证
|
||||
// 使用 cast.ToString 自动处理所有数字类型转换(int, int8-int64, uint, uint8-uint64, float32, float64)
|
||||
// 用法:在 PrepareForValidation 方法中调用此函数处理需要 in 验证的数字字段
|
||||
// 示例:return PrepareNumericFieldForValidation(data, "status")
|
||||
func PrepareNumericFieldForValidation(data validation.Data, fieldName string) error {
|
||||
if val, exist := data.Get(fieldName); exist {
|
||||
return data.Set(fieldName, cast.ToString(val))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
httpmiddleware "github.com/goravel/framework/http/middleware"
|
||||
|
||||
// sessionmiddleware "github.com/goravel/framework/session/middleware" // 已禁用
|
||||
|
||||
appmiddleware "goravel/app/http/middleware"
|
||||
)
|
||||
|
||||
type Kernel struct {
|
||||
}
|
||||
|
||||
// The application's global HTTP middleware stack.
|
||||
// These middleware are run during every request to your application.
|
||||
func (kernel Kernel) Middleware() []http.Middleware {
|
||||
return []http.Middleware{
|
||||
appmiddleware.Cors(), // CORS 跨域处理(需要在最前面处理预检请求)
|
||||
appmiddleware.Blacklist(), // 黑名单检查
|
||||
appmiddleware.Trace(),
|
||||
httpmiddleware.Throttle("global"),
|
||||
// sessionmiddleware.StartSession(), // 已禁用:项目使用 JWT 认证,不需要 Session
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
"goravel/app/models"
|
||||
"goravel/app/utils"
|
||||
)
|
||||
|
||||
func Blacklist() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 排除登录接口,避免管理员被封禁后无法登录
|
||||
path := ctx.Request().Path()
|
||||
if path == "/api/admin/login" || path == "/api/admin/login/captcha" {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取真实IP地址
|
||||
realIP := helpers.GetRealIP(ctx)
|
||||
|
||||
// 查询所有启用的黑名单记录
|
||||
var blacklists []models.Blacklist
|
||||
if err := facades.Orm().Query().Where("status", 1).Get(&blacklists); err != nil {
|
||||
// 如果查询失败,记录错误但继续处理请求(避免影响系统正常运行)
|
||||
facades.Log().Errorf("Blacklist middleware: Failed to query blacklists: %v", err)
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查IP是否在黑名单中
|
||||
for _, blacklist := range blacklists {
|
||||
if utils.IsIPInBlacklist(realIP, blacklist.IP) {
|
||||
// IP在黑名单中,拒绝访问
|
||||
facades.Log().Warningf("Blacklist middleware: IP %s blocked by blacklist ID %d", realIP, blacklist.ID)
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": http.StatusForbidden,
|
||||
"message": trans.Get(ctx, "ip_blocked"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// IP不在黑名单中,继续处理请求
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
)
|
||||
|
||||
// Cors CORS 中间件,处理跨域请求
|
||||
func Cors() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 获取请求路径
|
||||
path := ctx.Request().Path()
|
||||
|
||||
// 检查是否是 WebSocket 升级请求
|
||||
isWebSocket := strings.ToLower(ctx.Request().Header("Upgrade", "")) == "websocket" ||
|
||||
strings.ToLower(ctx.Request().Header("Connection", "")) == "upgrade"
|
||||
|
||||
// 获取 CORS 配置的路径列表
|
||||
corsPaths := facades.Config().Get("cors.paths", []string{}).([]string)
|
||||
|
||||
// 检查当前路径是否需要 CORS 处理
|
||||
needCors := false
|
||||
if len(corsPaths) == 0 {
|
||||
// 如果没有配置路径,默认对所有路径启用
|
||||
needCors = true
|
||||
} else {
|
||||
for _, corsPath := range corsPaths {
|
||||
// 支持通配符匹配
|
||||
if strings.HasSuffix(corsPath, "*") {
|
||||
prefix := strings.TrimSuffix(corsPath, "*")
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
needCors = true
|
||||
break
|
||||
}
|
||||
} else if path == corsPath {
|
||||
needCors = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket 请求直接放行,不需要 CORS 处理(WebSocket 有自己的协议)
|
||||
if isWebSocket {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !needCors {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 CORS 配置
|
||||
allowedOrigins := facades.Config().Get("cors.allowed_origins", []string{"*"}).([]string)
|
||||
allowedMethods := facades.Config().Get("cors.allowed_methods", []string{"*"}).([]string)
|
||||
allowedHeaders := facades.Config().Get("cors.allowed_headers", []string{"*"}).([]string)
|
||||
exposedHeaders := facades.Config().Get("cors.exposed_headers", []string{}).([]string)
|
||||
maxAge := facades.Config().GetInt("cors.max_age", 0)
|
||||
supportsCredentials := facades.Config().GetBool("cors.supports_credentials", false)
|
||||
|
||||
// 获取请求的 Origin
|
||||
origin := ctx.Request().Header("Origin", "")
|
||||
|
||||
// 检查是否允许该 Origin
|
||||
allowed := false
|
||||
var allowedOrigin string
|
||||
|
||||
if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" {
|
||||
// 允许所有源
|
||||
allowed = true
|
||||
allowedOrigin = "*"
|
||||
} else if origin != "" {
|
||||
// 检查是否在允许列表中
|
||||
if slices.Contains(allowedOrigins, origin) {
|
||||
allowed = true
|
||||
allowedOrigin = origin
|
||||
}
|
||||
}
|
||||
|
||||
// 处理预检请求 (OPTIONS) - 必须在设置其他头之前处理
|
||||
if ctx.Request().Method() == "OPTIONS" {
|
||||
// 对于预检请求,必须设置 CORS 头
|
||||
// 即使 origin 不在允许列表中,也要返回 CORS 头(只是不设置 Access-Control-Allow-Origin)
|
||||
// 这样浏览器才能正确判断,而不是因为状态码问题而失败
|
||||
|
||||
// 创建响应对象并设置所有 CORS 头
|
||||
response := ctx.Response()
|
||||
|
||||
if allowed && origin != "" {
|
||||
// Origin 在允许列表中
|
||||
response.Header("Access-Control-Allow-Origin", allowedOrigin)
|
||||
if supportsCredentials && allowedOrigin != "*" {
|
||||
response.Header("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
} else if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" {
|
||||
// 配置允许所有源
|
||||
response.Header("Access-Control-Allow-Origin", "*")
|
||||
} else if origin != "" {
|
||||
// Origin 不在允许列表中,不设置 Access-Control-Allow-Origin
|
||||
// 浏览器会拒绝请求,但至少不会因为状态码问题而失败
|
||||
}
|
||||
|
||||
// 设置允许的方法(对于预检请求,这些头必须设置)
|
||||
methodsStr := "*"
|
||||
if len(allowedMethods) > 0 && allowedMethods[0] != "*" {
|
||||
methodsStr = strings.Join(allowedMethods, ", ")
|
||||
}
|
||||
response.Header("Access-Control-Allow-Methods", methodsStr)
|
||||
|
||||
// 设置允许的请求头
|
||||
headersStr := "*"
|
||||
if len(allowedHeaders) > 0 && allowedHeaders[0] != "*" {
|
||||
headersStr = strings.Join(allowedHeaders, ", ")
|
||||
}
|
||||
response.Header("Access-Control-Allow-Headers", headersStr)
|
||||
|
||||
// 设置暴露的响应头
|
||||
if len(exposedHeaders) > 0 {
|
||||
response.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
|
||||
}
|
||||
|
||||
// 设置预检请求的缓存时间
|
||||
if maxAge > 0 {
|
||||
response.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
|
||||
}
|
||||
|
||||
// 返回 204 No Content 并终止请求处理
|
||||
// 对于 OPTIONS 请求,返回空响应体,状态码为 204
|
||||
// 使用 Json 方法返回空对象,然后调用 Abort() 终止请求
|
||||
_ = response.Json(http.StatusNoContent, http.Json{}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 对于非预检请求,设置 CORS 响应头
|
||||
if allowed && origin != "" {
|
||||
ctx.Response().Header("Access-Control-Allow-Origin", allowedOrigin)
|
||||
if supportsCredentials && allowedOrigin != "*" {
|
||||
ctx.Response().Header("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
} else if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" && origin != "" {
|
||||
// 如果配置允许所有源,且请求有 origin,设置 CORS 头
|
||||
ctx.Response().Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
|
||||
// 设置暴露的响应头(非预检请求)
|
||||
if len(exposedHeaders) > 0 {
|
||||
ctx.Response().Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
|
||||
}
|
||||
|
||||
// 继续处理请求
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
)
|
||||
|
||||
func DevelopmentOnly() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
env := facades.Config().Get("app.env", "production")
|
||||
if env != "local" && env != "development" {
|
||||
ctx.Response().Json(http.StatusForbidden, map[string]any{
|
||||
"code": 403,
|
||||
"message": "This feature is only available in development mode",
|
||||
})
|
||||
ctx.Request().Abort()
|
||||
return
|
||||
}
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
)
|
||||
|
||||
// Domain 域名验证中间件
|
||||
func Domain(configValueOrDomains ...any) http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
var domains []string
|
||||
|
||||
// 如果没有参数,不验证(允许所有域名)
|
||||
if len(configValueOrDomains) == 0 {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析配置值的辅助函数
|
||||
parseConfigValue := func(value any) []string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
// 分割逗号分隔的域名
|
||||
domainsList := strings.Split(v, ",")
|
||||
result := make([]string, 0, len(domainsList))
|
||||
for _, d := range domainsList {
|
||||
d = strings.TrimSpace(d)
|
||||
if d != "" {
|
||||
result = append(result, d)
|
||||
}
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok && str != "" {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 处理参数
|
||||
for _, param := range configValueOrDomains {
|
||||
if param == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := param.(type) {
|
||||
case []string:
|
||||
// 如果是字符串数组,直接使用
|
||||
if len(v) > 0 {
|
||||
domains = append(domains, v...)
|
||||
}
|
||||
case string:
|
||||
// 如果是字符串,可能是单个域名或配置键
|
||||
if v != "" {
|
||||
// 先尝试作为配置键读取
|
||||
configValue := facades.Config().Get(v, nil)
|
||||
if configValue != nil {
|
||||
// 如果配置存在,解析配置值
|
||||
parsedDomains := parseConfigValue(configValue)
|
||||
if len(parsedDomains) > 0 {
|
||||
domains = append(domains, parsedDomains...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 否则当作域名
|
||||
domains = append(domains, v)
|
||||
}
|
||||
case []any:
|
||||
// 如果是 any 数组,递归处理
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok && str != "" {
|
||||
domains = append(domains, str)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 其他类型,尝试解析为配置值
|
||||
parsedDomains := parseConfigValue(v)
|
||||
if len(parsedDomains) > 0 {
|
||||
domains = append(domains, parsedDomains...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有指定允许的域名,允许所有域名访问(直接放行)
|
||||
if len(domains) == 0 {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取请求的 Host
|
||||
// 优先从 X-Forwarded-Host 获取(适用于反向代理场景)
|
||||
host := ctx.Request().Header("X-Forwarded-Host", "")
|
||||
if host == "" {
|
||||
// 使用框架提供的 Host() 方法获取(推荐方式)
|
||||
host = ctx.Request().Host()
|
||||
}
|
||||
|
||||
// 如果 X-Forwarded-Host 包含多个值(逗号分隔),取第一个
|
||||
if host != "" && strings.Contains(host, ",") {
|
||||
host = strings.TrimSpace(strings.Split(host, ",")[0])
|
||||
}
|
||||
|
||||
// 调试日志:记录获取到的 Host 值
|
||||
if facades.Config().GetBool("app.debug", false) {
|
||||
facades.Log().Debugf("Domain middleware: Host detection - X-Forwarded-Host: %s, Host(): %s, Final host: %s",
|
||||
ctx.Request().Header("X-Forwarded-Host", ""),
|
||||
ctx.Request().Host(),
|
||||
host)
|
||||
}
|
||||
|
||||
// 规范化 Host(移除端口号,转换为小写)
|
||||
normalizedHost := normalizeHost(host)
|
||||
|
||||
// 调试日志:记录规范化后的 Host 和配置的域名
|
||||
if facades.Config().GetBool("app.debug", false) {
|
||||
facades.Log().Debugf("Domain middleware: Normalized host: %s, Configured domains: %v", normalizedHost, domains)
|
||||
}
|
||||
|
||||
// 检查是否在允许的域名列表中
|
||||
allowed := false
|
||||
var matchedDomain string
|
||||
for _, allowedDomain := range domains {
|
||||
normalizedAllowed := normalizeHost(allowedDomain)
|
||||
// 支持精确匹配和通配符匹配
|
||||
if normalizedHost == normalizedAllowed || matchDomain(normalizedHost, normalizedAllowed) {
|
||||
allowed = true
|
||||
matchedDomain = allowedDomain
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
// 域名不在允许列表中,拒绝访问
|
||||
// facades.Log().Warningf("Domain middleware: Access denied. Request host: %s (normalized: %s), Allowed domains: %v", host, normalizedHost, domains)
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": http.StatusForbidden,
|
||||
"message": "Access denied: domain not allowed",
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 记录匹配的域名(仅在调试模式下)
|
||||
if facades.Config().GetBool("app.debug", false) {
|
||||
facades.Log().Debugf("Domain middleware: Access allowed. Request host: %s, Matched domain: %s", normalizedHost, matchedDomain)
|
||||
}
|
||||
|
||||
// 域名验证通过,继续处理请求
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeHost 规范化域名(移除端口号,转换为小写,去除前后空格)
|
||||
func normalizeHost(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 移除端口号
|
||||
if hostname, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = hostname
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
// matchDomain 域名匹配,支持通配符
|
||||
// 例如:*.example.com 可以匹配 a.example.com, b.example.com 等
|
||||
func matchDomain(host, pattern string) bool {
|
||||
if pattern == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模式以 * 开头,进行通配符匹配
|
||||
if after, ok := strings.CutPrefix(pattern, "*."); ok {
|
||||
// 移除 *.
|
||||
suffix := after
|
||||
// 检查 host 是否以 .suffix 结尾
|
||||
if strings.HasSuffix(host, "."+suffix) || host == suffix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 如果模式以 * 结尾,进行前缀匹配
|
||||
if before, ok := strings.CutSuffix(pattern, ".*"); ok {
|
||||
prefix := before
|
||||
if strings.HasPrefix(host, prefix+".") || host == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
"github.com/goravel/framework/support/str"
|
||||
|
||||
"goravel/app/http/trans"
|
||||
"goravel/app/models"
|
||||
"goravel/app/services"
|
||||
"goravel/app/utils/logger"
|
||||
)
|
||||
|
||||
// min 返回两个整数中的较小值
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func Jwt() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 如果路径是api/admin前缀,使用admin guard
|
||||
path := ctx.Request().Path()
|
||||
pathStr := str.Of(path)
|
||||
if pathStr.IsEmpty() || (!pathStr.StartsWith("/api/admin") && !pathStr.StartsWith("/admin")) {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
token := ctx.Request().Header("Authorization", "")
|
||||
|
||||
// 如果 Header 中没有 token,尝试从 URL 参数中获取(用于 SSE 等不支持自定义 headers 的场景)
|
||||
if str.Of(token).IsEmpty() {
|
||||
token = ctx.Request().Query("_token", "")
|
||||
}
|
||||
|
||||
if str.Of(token).IsEmpty() {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 移除Bearer前缀(如果有)
|
||||
token = str.Of(token).ChopStart("Bearer ").Trim().String()
|
||||
|
||||
if token == "" {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 从数据库查找token
|
||||
tokenService := services.NewTokenServiceImpl()
|
||||
accessToken, err := tokenService.FindToken(token)
|
||||
if err != nil {
|
||||
// token查找失败或已过期
|
||||
logger.ErrorfHTTP(ctx, "JWT middleware: FindToken error: %v, token prefix: %s", err, token[:min(20, len(token))])
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "invalid_token"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
if accessToken == nil {
|
||||
logger.ErrorfHTTP(ctx, "JWT middleware: accessToken is nil, token prefix: %s", token[:min(20, len(token))])
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "invalid_token"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查token类型
|
||||
if accessToken.TokenableType != "admin" {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "invalid_token"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
var admin models.Admin
|
||||
if err := facades.Orm().Query().Where("id", accessToken.TokenableID).First(&admin); err != nil {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "user_not_found"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
_ = tokenService.UpdateLastUsedAt(token)
|
||||
|
||||
// 滑动过期:如果token有过期时间,每次请求时自动延长过期时间
|
||||
if accessToken.ExpiresAt != nil {
|
||||
ttl := facades.Config().GetInt("jwt.ttl", 60) // 默认60分钟
|
||||
if ttl > 0 {
|
||||
newExpiresAt := time.Now().Add(time.Duration(ttl) * time.Minute)
|
||||
// 更新token的过期时间
|
||||
_, _ = facades.Orm().Query().
|
||||
Model(&models.PersonalAccessToken{}).
|
||||
Where("id", accessToken.ID).
|
||||
Update("expires_at", newExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// 将用户信息存储到context中,供后续中间件使用
|
||||
ctx.WithValue("admin", admin)
|
||||
ctx.WithValue("token", accessToken)
|
||||
|
||||
// facades.Log().Debugf("JWT middleware: admin set in context, ID: %d, Username: %s", admin.ID, admin.Username)
|
||||
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
httpcontract "github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
|
||||
"goravel/app/utils"
|
||||
)
|
||||
|
||||
// Lang 多语言中间件,从请求头获取语言
|
||||
func Lang() httpcontract.Middleware {
|
||||
return func(ctx httpcontract.Context) {
|
||||
// 使用通用工具函数获取语言
|
||||
lang := utils.GetCurrentLanguage(ctx)
|
||||
facades.App().SetLocale(ctx, lang)
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opentracing/opentracing-go"
|
||||
"github.com/opentracing/opentracing-go/ext"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
const (
|
||||
OpentracingTracer = "opentracing_tracer"
|
||||
OpentracingCtx = "opentracing_ctx"
|
||||
)
|
||||
|
||||
func Opentracing(tracer opentracing.Tracer) http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
var parentSpan opentracing.Span
|
||||
|
||||
spCtx, err := opentracing.GlobalTracer().Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(ctx.Request().Headers()))
|
||||
if err != nil {
|
||||
parentSpan = tracer.StartSpan(ctx.Request().Path())
|
||||
defer parentSpan.Finish()
|
||||
} else {
|
||||
parentSpan = opentracing.StartSpan(
|
||||
ctx.Request().Path(),
|
||||
opentracing.ChildOf(spCtx),
|
||||
opentracing.Tag{Key: string(ext.Component), Value: "HTTP"},
|
||||
ext.SpanKindRPCServer,
|
||||
)
|
||||
defer parentSpan.Finish()
|
||||
}
|
||||
|
||||
ctx.WithValue(OpentracingTracer, tracer)
|
||||
ctx.WithValue(OpentracingCtx, opentracing.ContextWithSpan(context.Background(), parentSpan))
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/models"
|
||||
"goravel/app/services"
|
||||
"goravel/app/utils"
|
||||
"goravel/app/utils/logger"
|
||||
"goravel/app/utils/traceid"
|
||||
)
|
||||
|
||||
// OperationLog 操作日志中间件
|
||||
func OperationLog() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
systemLogService := services.NewSystemLogService()
|
||||
startTime := time.Now()
|
||||
|
||||
// 获取请求信息
|
||||
method := ctx.Request().Method()
|
||||
path := ctx.Request().Path()
|
||||
ip := ctx.Request().Ip()
|
||||
userAgent := ctx.Request().Header("User-Agent", "")
|
||||
|
||||
// 获取请求参数(排除敏感信息)
|
||||
var requestBody string
|
||||
if method == "POST" || method == "PUT" || method == "PATCH" {
|
||||
// 获取所有输入参数
|
||||
inputs := make(map[string]any)
|
||||
// 记录所有非敏感参数
|
||||
allInputs := ctx.Request().All()
|
||||
for key, value := range allInputs {
|
||||
// 使用工具函数检查是否是敏感字段
|
||||
if utils.IsSensitiveField(key) {
|
||||
inputs[key] = "***"
|
||||
} else {
|
||||
inputs[key] = value
|
||||
}
|
||||
}
|
||||
if data, err := json.Marshal(inputs); err == nil {
|
||||
requestBody = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取管理员ID(从JWT中间件设置的context中获取)
|
||||
var adminID uint
|
||||
if admin, err := helpers.GetAdminFromContext(ctx); err == nil {
|
||||
adminID = admin.ID
|
||||
}
|
||||
|
||||
// 继续处理请求
|
||||
ctx.Request().Next()
|
||||
|
||||
// 计算耗时
|
||||
duration := int(time.Since(startTime).Milliseconds())
|
||||
|
||||
// 只记录新增、修改、删除操作(POST、PUT、PATCH、DELETE),排除 GET 请求
|
||||
// 同时排除登录和info接口,以及分片上传的进度查询(GET请求)
|
||||
// 排除代码生成器相关操作
|
||||
// 对于分片上传,只记录 merge 操作(最终完成上传),排除 init 和 upload 操作
|
||||
if (method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE") &&
|
||||
path != "/api/admin/login" && path != "/api/admin/info" &&
|
||||
!strings.HasPrefix(path, "/api/admin/code-generator/") {
|
||||
|
||||
// 排除分片上传的中间操作(init 和 upload),只记录 merge(最终完成上传)
|
||||
if path == "/api/admin/attachments/chunk" {
|
||||
action := ctx.Request().Input("action", "")
|
||||
if action == "" {
|
||||
action = ctx.Request().Query("action", "")
|
||||
}
|
||||
// 只记录 merge 操作,排除 init、upload 和 progress
|
||||
if action != "merge" {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 在请求处理后再获取一次管理员ID(确保JWT中间件已执行)
|
||||
// 如果之前没有获取到,再次尝试从context获取
|
||||
if adminID == 0 {
|
||||
if admin, err := helpers.GetAdminFromContext(ctx); err == nil {
|
||||
adminID = admin.ID
|
||||
}
|
||||
}
|
||||
|
||||
// 默认状态为成功
|
||||
status := uint8(1)
|
||||
var errorMsg string
|
||||
|
||||
// 在goroutine之前保存所有需要的数据,避免context问题
|
||||
savedAdminID := adminID
|
||||
savedMethod := method
|
||||
savedPath := path
|
||||
savedIP := ip
|
||||
savedUserAgent := userAgent
|
||||
savedRequestBody := requestBody
|
||||
savedDuration := duration
|
||||
|
||||
// 提前获取 traceCtx,用于日志记录
|
||||
traceCtx := traceid.DeriveContextFromHTTP(ctx)
|
||||
|
||||
// 生成操作标题(只使用权限标识)
|
||||
title := utils.GetOperationTitleFromContext(ctx)
|
||||
if title == "operation.unknown" {
|
||||
// 如果无法生成标题,记录调试日志
|
||||
logger.ErrorfContext(traceCtx, "Failed to generate operation title, method: %s, path: %s", savedMethod, savedPath)
|
||||
}
|
||||
|
||||
operationLog := models.OperationLog{
|
||||
AdminID: savedAdminID,
|
||||
Method: savedMethod,
|
||||
Path: savedPath,
|
||||
Title: title,
|
||||
IP: savedIP,
|
||||
UserAgent: savedUserAgent,
|
||||
Request: savedRequestBody,
|
||||
Status: status,
|
||||
ErrorMsg: errorMsg,
|
||||
Duration: savedDuration,
|
||||
}
|
||||
|
||||
// 异步记录日志,避免影响响应速度
|
||||
go func(ctx context.Context) {
|
||||
if err := facades.Orm().Query().Create(&operationLog); err != nil {
|
||||
_ = systemLogService.Record(ctx, "error", "operation-log", "failed to persist operation log", map[string]any{
|
||||
"error": err.Error(),
|
||||
"path": savedPath,
|
||||
})
|
||||
logger.ErrorfContext(ctx, "Failed to create operation log: %v", err)
|
||||
}
|
||||
}(traceCtx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
|
||||
"goravel/app/http/trans"
|
||||
"goravel/app/models"
|
||||
"goravel/app/services"
|
||||
"goravel/app/utils/errorlog"
|
||||
"goravel/app/utils/logger"
|
||||
)
|
||||
|
||||
// Permission 权限验证中间件
|
||||
func Permission() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 从context中获取admin信息(由JWT中间件设置)
|
||||
adminValue := ctx.Value("admin")
|
||||
if adminValue == nil {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": 401,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
admin, ok := adminValue.(models.Admin)
|
||||
if !ok {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": 401,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 加载管理员的角色、权限等关联数据
|
||||
adminService := services.NewAdminServiceImpl()
|
||||
if err := adminService.LoadRelationsWithPermissions(&admin); err != nil {
|
||||
logger.ErrorfHTTP(ctx, "permission middleware load relations failed: %v", err)
|
||||
errorlog.RecordHTTP(ctx, "permission", "Failed to load admin relations with permissions", map[string]any{
|
||||
"error": err.Error(),
|
||||
"admin_id": admin.ID,
|
||||
"path": ctx.Request().Path(),
|
||||
}, "Load admin relations failed: %v", err)
|
||||
_ = ctx.Response().Json(http.StatusInternalServerError, http.Json{
|
||||
"code": 500,
|
||||
"message": trans.Get(ctx, "load_permissions_failed"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是超级管理员
|
||||
// 拥有 super-admin 角色的管理员(包括超级管理员和开发者管理员)都跳过权限“拦截”,但仍然参与权限匹配,用于生成操作标题
|
||||
isSuperAdmin := false
|
||||
for _, role := range admin.Roles {
|
||||
if role.Slug == "super-admin" && role.Status == 1 {
|
||||
isSuperAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前请求的方法和路径
|
||||
method := ctx.Request().Method()
|
||||
path := ctx.Request().Path()
|
||||
|
||||
// 收集所有角色的权限(已通过预加载获取)
|
||||
var allPermissions []models.Permission
|
||||
for _, role := range admin.Roles {
|
||||
if role.Status == 1 {
|
||||
allPermissions = append(allPermissions, role.Permissions...)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有权限,并记录匹配的权限标识
|
||||
hasPermission := false
|
||||
var matchedPermissionSlug string
|
||||
var menuDisabled bool
|
||||
for _, perm := range allPermissions {
|
||||
if perm.Status == 1 {
|
||||
// 检查方法匹配
|
||||
if perm.Method == "" || perm.Method == method {
|
||||
// 检查路径匹配(支持通配符)
|
||||
if perm.Path == "" || perm.Path == path || matchPath(perm.Path, path) {
|
||||
// 检查关联菜单的状态(如果权限关联了菜单)
|
||||
// 如果权限没有关联菜单(MenuID = 0),则允许访问
|
||||
// 如果权限关联了菜单,需要检查菜单状态是否为启用(status = 1)
|
||||
if perm.MenuID == 0 {
|
||||
// 权限没有关联菜单,允许访问
|
||||
hasPermission = true
|
||||
matchedPermissionSlug = perm.Slug
|
||||
break
|
||||
} else if perm.Menu.ID > 0 {
|
||||
// 权限关联了菜单,检查菜单状态
|
||||
if perm.Menu.Status == 1 {
|
||||
// 菜单状态为启用,允许访问
|
||||
hasPermission = true
|
||||
matchedPermissionSlug = perm.Slug
|
||||
break
|
||||
} else {
|
||||
// 菜单状态为关闭,记录但继续查找其他权限
|
||||
menuDisabled = true
|
||||
}
|
||||
}
|
||||
// 如果菜单没有加载(perm.Menu.ID == 0),为了安全起见,不允许访问
|
||||
// 这种情况应该很少发生,因为我们已经预加载了菜单
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非超级管理员且无匹配权限时拦截;超级管理员即使无匹配权限也放行
|
||||
if !hasPermission && !isSuperAdmin {
|
||||
// 如果是因为菜单状态为关闭而禁止访问,返回更具体的错误信息
|
||||
if menuDisabled {
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": 403,
|
||||
"message": trans.Get(ctx, "menu_disabled"),
|
||||
}).Abort()
|
||||
} else {
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": 403,
|
||||
"message": trans.Get(ctx, "no_permission"),
|
||||
}).Abort()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 将匹配的权限标识存储到 context 中,供操作日志使用
|
||||
if matchedPermissionSlug != "" {
|
||||
ctx.WithValue("permission_slug", matchedPermissionSlug)
|
||||
}
|
||||
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
// matchPath 路径匹配,支持通配符
|
||||
// 支持的模式:
|
||||
// 1. 精确匹配:/api/admin/roles 匹配 /api/admin/roles
|
||||
// 2. 末尾通配符:/api/admin/roles/* 匹配 /api/admin/roles/1
|
||||
// 3. 中间通配符:/api/admin/attachments/*/display-name 匹配 /api/admin/attachments/1/display-name
|
||||
func matchPath(pattern, path string) bool {
|
||||
if pattern == path {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果模式不包含通配符,直接返回 false
|
||||
if !contains(pattern, '*') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 将模式按 * 分割成多个部分
|
||||
parts := splitPattern(pattern)
|
||||
if len(parts) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模式以 * 开头,需要特殊处理
|
||||
if pattern[0] == '*' {
|
||||
// 检查路径是否以模式的剩余部分结尾
|
||||
if len(parts) > 1 {
|
||||
suffix := parts[1]
|
||||
return len(path) >= len(suffix) && path[len(path)-len(suffix):] == suffix
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果模式以 * 结尾
|
||||
if pattern[len(pattern)-1] == '*' {
|
||||
prefix := pattern[:len(pattern)-1]
|
||||
if len(path) >= len(prefix) {
|
||||
pathPrefix := path[:len(prefix)]
|
||||
if pathPrefix == prefix {
|
||||
// 如果前缀以 / 结尾,路径必须比前缀长(即后面还有内容)
|
||||
if len(prefix) > 0 && prefix[len(prefix)-1] == '/' {
|
||||
return len(path) > len(prefix)
|
||||
}
|
||||
// 如果前缀不以 / 结尾,路径可以等于或长于前缀
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 处理中间有通配符的情况,如 /api/admin/attachments/*/display-name
|
||||
// 将模式按 * 分割
|
||||
patternParts := splitPattern(pattern)
|
||||
if len(patternParts) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 过滤掉 "*" 标记,只保留实际的部分
|
||||
var actualParts []string
|
||||
for _, part := range patternParts {
|
||||
if part != "*" {
|
||||
actualParts = append(actualParts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if len(actualParts) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查路径是否以第一部分开头
|
||||
firstPart := actualParts[0]
|
||||
if len(path) < len(firstPart) || path[:len(firstPart)] != firstPart {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查路径是否以最后一部分结尾
|
||||
lastPart := actualParts[len(actualParts)-1]
|
||||
if len(path) < len(lastPart) || path[len(path)-len(lastPart):] != lastPart {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查中间部分是否存在(通配符匹配任意内容)
|
||||
// 路径应该是:firstPart + 任意内容 + lastPart
|
||||
remainingPath := path[len(firstPart) : len(path)-len(lastPart)]
|
||||
// 确保中间部分不为空(至少有一个字符,通常是数字ID)
|
||||
return len(remainingPath) > 0
|
||||
}
|
||||
|
||||
// contains 检查字符串是否包含指定字符
|
||||
func contains(s string, c byte) bool {
|
||||
for i := range s {
|
||||
if s[i] == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitPattern 按 * 分割模式字符串
|
||||
func splitPattern(pattern string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
|
||||
for i := range pattern {
|
||||
if pattern[i] == '*' {
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
parts = append(parts, "*")
|
||||
} else {
|
||||
current.WriteByte(pattern[i])
|
||||
}
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
|
||||
"goravel/app/http/helpers"
|
||||
)
|
||||
|
||||
// TimezoneQuery 时区查询参数转换中间件
|
||||
// 自动将查询参数中的时间字段从本地时区转换为 UTC 时间用于数据库查询
|
||||
func TimezoneQuery() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 定义需要转换的时间查询参数名称
|
||||
timeParams := []string{
|
||||
"start_time",
|
||||
"end_time",
|
||||
"created_at_start",
|
||||
"created_at_end",
|
||||
"updated_at_start",
|
||||
"updated_at_end",
|
||||
"deleted_at_start",
|
||||
"deleted_at_end",
|
||||
}
|
||||
|
||||
// 转换每个时间参数
|
||||
for _, paramName := range timeParams {
|
||||
timeStr := ctx.Request().Query(paramName, "")
|
||||
if timeStr != "" {
|
||||
// 转换为 UTC 时间并存储在 context 中
|
||||
utcTime := helpers.ConvertTimeToUTC(ctx, timeStr)
|
||||
// 将转换后的时间存储在 context 中,供控制器使用
|
||||
ctx.WithValue("timezone_query_"+paramName, utcTime)
|
||||
}
|
||||
}
|
||||
|
||||
// 继续处理请求
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
|
||||
"goravel/app/utils/traceid"
|
||||
)
|
||||
|
||||
// Trace middleware ensures every request carries a trace id and mirrors it in response headers.
|
||||
func Trace() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
traceID := traceid.EnsureHTTPContext(ctx, "")
|
||||
ctx.Response().Header(traceid.HeaderName(), traceID)
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
"github.com/goravel/framework/support/str"
|
||||
|
||||
"goravel/app/http/trans"
|
||||
"goravel/app/models"
|
||||
)
|
||||
|
||||
// UserJwt C端用户JWT认证中间件(使用Goravel标准Auth)
|
||||
func UserJwt() http.Middleware {
|
||||
return func(ctx http.Context) {
|
||||
// 如果路径是api/user前缀,使用user guard
|
||||
path := ctx.Request().Path()
|
||||
pathStr := str.Of(path)
|
||||
if pathStr.IsEmpty() || !pathStr.StartsWith("/api/user") {
|
||||
ctx.Request().Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 使用Goravel标准Auth解析token
|
||||
if _, err := facades.Auth(ctx).Guard("user").Parse(ctx.Request().Header("Authorization", "")); err != nil {
|
||||
// 如果Header中没有token,尝试从URL参数中获取
|
||||
if token := ctx.Request().Query("_token", ""); token != "" {
|
||||
if _, err := facades.Auth(ctx).Guard("user").Parse(token); err != nil {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "invalid_token"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "not_logged_in"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
var user models.User
|
||||
if err := facades.Auth(ctx).Guard("user").User(&user); err != nil {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "user_not_found"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID == 0 {
|
||||
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
|
||||
"code": http.StatusUnauthorized,
|
||||
"message": trans.Get(ctx, "user_not_found"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if user.Status == 0 {
|
||||
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
|
||||
"code": http.StatusForbidden,
|
||||
"message": trans.Get(ctx, "account_disabled"),
|
||||
}).Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存储到context中,供后续中间件使用
|
||||
ctx.WithValue("user", user)
|
||||
|
||||
ctx.Request().Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type AdminCreate struct {
|
||||
Username string `form:"username" json:"username"`
|
||||
Password string `form:"password" json:"password"`
|
||||
Nickname string `form:"nickname" json:"nickname"`
|
||||
Email string `form:"email" json:"email"`
|
||||
Phone string `form:"phone" json:"phone"`
|
||||
DepartmentID uint `form:"department_id" json:"department_id"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
RoleIDs []uint `form:"role_ids" json:"role_ids"`
|
||||
}
|
||||
|
||||
func (r *AdminCreate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AdminCreate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": "required|min_len:3|max_len:50",
|
||||
"password": "required|min_len:6|max_len:50",
|
||||
"nickname": "max_len:50",
|
||||
"email": "email|max_len:100",
|
||||
"phone": "max_len:20",
|
||||
"status": "in:0,1",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AdminCreate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username.required": trans.Get(ctx, "validation_username_required"),
|
||||
"username.min_len": trans.Get(ctx, "validation_username_min"),
|
||||
"username.max_len": trans.Get(ctx, "validation_username_max"),
|
||||
"password.required": trans.Get(ctx, "validation_password_required"),
|
||||
"password.min_len": trans.Get(ctx, "validation_password_min"),
|
||||
"password.max_len": trans.Get(ctx, "validation_password_max"),
|
||||
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
|
||||
"email.email": trans.Get(ctx, "validation_email_format"),
|
||||
"email.max_len": trans.Get(ctx, "validation_email_max"),
|
||||
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AdminCreate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": trans.Get(ctx, "validation_username"),
|
||||
"password": trans.Get(ctx, "validation_password"),
|
||||
"nickname": trans.Get(ctx, "validation_nickname"),
|
||||
"email": trans.Get(ctx, "validation_email"),
|
||||
"phone": trans.Get(ctx, "validation_phone"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AdminCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
// 将 status 字段转换为字符串,以便 in 规则能正确验证
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type AdminUpdate struct {
|
||||
Nickname string `form:"nickname" json:"nickname"`
|
||||
Email string `form:"email" json:"email"`
|
||||
Phone string `form:"phone" json:"phone"`
|
||||
Password string `form:"password" json:"password"`
|
||||
DepartmentID uint `form:"department_id" json:"department_id"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
RoleIDs []uint `form:"role_ids" json:"role_ids"`
|
||||
}
|
||||
|
||||
func (r *AdminUpdate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AdminUpdate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"nickname": "max_len:50",
|
||||
"email": "email|max_len:100",
|
||||
"phone": "max_len:20",
|
||||
"password": "min_len:6|max_len:50",
|
||||
"status": "in:0,1",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AdminUpdate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
|
||||
"email.email": trans.Get(ctx, "validation_email_format"),
|
||||
"email.max_len": trans.Get(ctx, "validation_email_max"),
|
||||
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
|
||||
"password.min_len": trans.Get(ctx, "validation_password_min"),
|
||||
"password.max_len": trans.Get(ctx, "validation_password_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AdminUpdate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"nickname": trans.Get(ctx, "validation_nickname"),
|
||||
"email": trans.Get(ctx, "validation_email"),
|
||||
"phone": trans.Get(ctx, "validation_phone"),
|
||||
"password": trans.Get(ctx, "validation_password"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AdminUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
// 将 status 字段转换为字符串,以便 in 规则能正确验证
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
type ArticleCreate struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Status string `form:"status" json:"status"`
|
||||
}
|
||||
|
||||
func (r *ArticleCreate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ArticleCreate) Rules(ctx http.Context) map[string]string {
|
||||
rules := map[string]string{
|
||||
|
||||
"name": "required",
|
||||
"status": "",
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func (r *ArticleCreate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
|
||||
"name.required": trans.Get(ctx, "validation_name_required"),
|
||||
"status.required": trans.Get(ctx, "validation_status_required"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ArticleCreate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
|
||||
"name": trans.Get(ctx, "validation_name"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
type ArticleUpdate struct {
|
||||
Name *string `form:"name" json:"name"`
|
||||
Status *string `form:"status" json:"status"`
|
||||
}
|
||||
|
||||
func (r *ArticleUpdate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ArticleUpdate) Rules(ctx http.Context) map[string]string {
|
||||
rules := map[string]string{
|
||||
|
||||
"name": "required",
|
||||
"status": "",
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func (r *ArticleUpdate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
|
||||
"name.required": trans.Get(ctx, "validation_name_required"),
|
||||
"status.required": trans.Get(ctx, "validation_status_required"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ArticleUpdate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
|
||||
"name": trans.Get(ctx, "validation_name"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type BlacklistCreate struct {
|
||||
IP string `form:"ip" json:"ip"`
|
||||
Remark string `form:"remark" json:"remark"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
}
|
||||
|
||||
func (r *BlacklistCreate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BlacklistCreate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"ip": "required",
|
||||
"status": "in:0,1",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BlacklistCreate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"ip.required": trans.Get(ctx, "ip_address_required"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BlacklistCreate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"ip": trans.Get(ctx, "validation_ip"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BlacklistCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type BlacklistUpdate struct {
|
||||
IP string `form:"ip" json:"ip"`
|
||||
Remark string `form:"remark" json:"remark"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
}
|
||||
|
||||
func (r *BlacklistUpdate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BlacklistUpdate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"status": "in:0,1",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BlacklistUpdate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BlacklistUpdate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BlacklistUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type DepartmentCreate struct {
|
||||
ParentID uint `form:"parent_id" json:"parent_id"`
|
||||
Name string `form:"name" json:"name"`
|
||||
Code string `form:"code" json:"code"`
|
||||
Leader string `form:"leader" json:"leader"`
|
||||
Phone string `form:"phone" json:"phone"`
|
||||
Email string `form:"email" json:"email"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
Remark string `form:"remark" json:"remark"`
|
||||
}
|
||||
|
||||
func (r *DepartmentCreate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DepartmentCreate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": "required|max_len:50",
|
||||
"code": "max_len:50",
|
||||
"leader": "max_len:50",
|
||||
"phone": "max_len:20",
|
||||
"email": "email|max_len:100",
|
||||
"status": "in:0,1",
|
||||
"remark": "max_len:500",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DepartmentCreate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name.required": trans.Get(ctx, "department_name_required"),
|
||||
"name.max_len": trans.Get(ctx, "validation_name_max"),
|
||||
"code.max_len": trans.Get(ctx, "validation_code_max"),
|
||||
"leader.max_len": trans.Get(ctx, "validation_leader_max"),
|
||||
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
|
||||
"email.email": trans.Get(ctx, "validation_email_format"),
|
||||
"email.max_len": trans.Get(ctx, "validation_email_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DepartmentCreate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": trans.Get(ctx, "validation_name"),
|
||||
"code": trans.Get(ctx, "validation_code"),
|
||||
"leader": trans.Get(ctx, "validation_leader"),
|
||||
"phone": trans.Get(ctx, "validation_phone"),
|
||||
"email": trans.Get(ctx, "validation_email"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
"remark": trans.Get(ctx, "validation_remark"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DepartmentCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type DepartmentUpdate struct {
|
||||
ParentID uint `form:"parent_id" json:"parent_id"`
|
||||
Name string `form:"name" json:"name"`
|
||||
Code string `form:"code" json:"code"`
|
||||
Leader string `form:"leader" json:"leader"`
|
||||
Phone string `form:"phone" json:"phone"`
|
||||
Email string `form:"email" json:"email"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
Remark string `form:"remark" json:"remark"`
|
||||
}
|
||||
|
||||
func (r *DepartmentUpdate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DepartmentUpdate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": "max_len:50",
|
||||
"code": "max_len:50",
|
||||
"leader": "max_len:50",
|
||||
"phone": "max_len:20",
|
||||
"email": "email|max_len:100",
|
||||
"status": "in:0,1",
|
||||
"remark": "max_len:500",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DepartmentUpdate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name.max_len": trans.Get(ctx, "validation_name_max"),
|
||||
"code.max_len": trans.Get(ctx, "validation_code_max"),
|
||||
"leader.max_len": trans.Get(ctx, "validation_leader_max"),
|
||||
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
|
||||
"email.email": trans.Get(ctx, "validation_email_format"),
|
||||
"email.max_len": trans.Get(ctx, "validation_email_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DepartmentUpdate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": trans.Get(ctx, "validation_name"),
|
||||
"code": trans.Get(ctx, "validation_code"),
|
||||
"leader": trans.Get(ctx, "validation_leader"),
|
||||
"phone": trans.Get(ctx, "validation_phone"),
|
||||
"email": trans.Get(ctx, "validation_email"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
"remark": trans.Get(ctx, "validation_remark"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DepartmentUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type DictionaryCreate struct {
|
||||
Type string `form:"type" json:"type"`
|
||||
Label string `form:"label" json:"label"`
|
||||
Value string `form:"value" json:"value"`
|
||||
TranslationKey string `form:"translation_key" json:"translation_key"`
|
||||
Description string `form:"description" json:"description"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
Remark string `form:"remark" json:"remark"`
|
||||
}
|
||||
|
||||
func (r *DictionaryCreate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DictionaryCreate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"type": "required|max_len:50",
|
||||
"label": "required|max_len:50",
|
||||
"value": "required|max_len:100",
|
||||
"translation_key": "max_len:255",
|
||||
"description": "max_len:255",
|
||||
"status": "in:0,1",
|
||||
"remark": "max_len:500",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DictionaryCreate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"type.required": trans.Get(ctx, "dictionary_type_required"),
|
||||
"type.max_len": trans.Get(ctx, "validation_type_max"),
|
||||
"label.required": trans.Get(ctx, "validation_label_required"),
|
||||
"label.max_len": trans.Get(ctx, "validation_label_max"),
|
||||
"value.required": trans.Get(ctx, "validation_value_required"),
|
||||
"value.max_len": trans.Get(ctx, "validation_value_max"),
|
||||
"translation_key.max_len": trans.Get(ctx, "validation_translation_key_max"),
|
||||
"description.max_len": trans.Get(ctx, "validation_description_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DictionaryCreate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"type": trans.Get(ctx, "validation_type"),
|
||||
"label": trans.Get(ctx, "validation_label"),
|
||||
"value": trans.Get(ctx, "validation_value"),
|
||||
"translation_key": trans.Get(ctx, "validation_translation_key"),
|
||||
"description": trans.Get(ctx, "validation_description"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
"remark": trans.Get(ctx, "validation_remark"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DictionaryCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type DictionaryUpdate struct {
|
||||
Type string `form:"type" json:"type"`
|
||||
Label string `form:"label" json:"label"`
|
||||
Value string `form:"value" json:"value"`
|
||||
TranslationKey string `form:"translation_key" json:"translation_key"`
|
||||
Description string `form:"description" json:"description"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
Remark string `form:"remark" json:"remark"`
|
||||
}
|
||||
|
||||
func (r *DictionaryUpdate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DictionaryUpdate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"type": "max_len:50",
|
||||
"label": "max_len:50",
|
||||
"value": "max_len:100",
|
||||
"translation_key": "max_len:255",
|
||||
"description": "max_len:255",
|
||||
"status": "in:0,1",
|
||||
"remark": "max_len:500",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DictionaryUpdate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"type.max_len": trans.Get(ctx, "validation_type_max"),
|
||||
"label.max_len": trans.Get(ctx, "validation_label_max"),
|
||||
"value.max_len": trans.Get(ctx, "validation_value_max"),
|
||||
"translation_key.max_len": trans.Get(ctx, "validation_translation_key_max"),
|
||||
"description.max_len": trans.Get(ctx, "validation_description_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DictionaryUpdate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"type": trans.Get(ctx, "validation_type"),
|
||||
"label": trans.Get(ctx, "validation_label"),
|
||||
"value": trans.Get(ctx, "validation_value"),
|
||||
"translation_key": trans.Get(ctx, "validation_translation_key"),
|
||||
"description": trans.Get(ctx, "validation_description"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
"remark": trans.Get(ctx, "validation_remark"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DictionaryUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
type Login struct {
|
||||
Username string `form:"username" json:"username"`
|
||||
Password string `form:"password" json:"password"`
|
||||
CaptchaID string `form:"captcha_id" json:"captcha_id"`
|
||||
CaptchaAnswer string `form:"captcha_answer" json:"captcha_answer"`
|
||||
GoogleCode string `form:"google_code" json:"google_code"` // 谷歌验证码
|
||||
}
|
||||
|
||||
func (r *Login) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Login) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": "required",
|
||||
"password": "required|min_len:6",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Login) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username.required": trans.Get(ctx, "validation_username_required"),
|
||||
"password.required": trans.Get(ctx, "validation_password_required"),
|
||||
"password.min_len": trans.Get(ctx, "validation_password_min"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Login) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": trans.Get(ctx, "validation_username"),
|
||||
"password": trans.Get(ctx, "validation_password"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type MenuCreate struct {
|
||||
ParentID uint `form:"parent_id" json:"parent_id"`
|
||||
Title string `form:"title" json:"title"`
|
||||
Slug string `form:"slug" json:"slug"`
|
||||
Icon string `form:"icon" json:"icon"`
|
||||
Path string `form:"path" json:"path"`
|
||||
Component string `form:"component" json:"component"`
|
||||
Permission string `form:"permission" json:"permission"`
|
||||
Type uint8 `form:"type" json:"type"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
IsHidden uint8 `form:"is_hidden" json:"is_hidden"`
|
||||
LinkType uint8 `form:"link_type" json:"link_type"`
|
||||
OpenType uint8 `form:"open_type" json:"open_type"`
|
||||
}
|
||||
|
||||
func (r *MenuCreate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MenuCreate) Rules(ctx http.Context) map[string]string {
|
||||
rules := map[string]string{
|
||||
"title": "required|max_len:50",
|
||||
"slug": "required|max_len:50",
|
||||
"icon": "max_len:50",
|
||||
"component": "max_len:255",
|
||||
"permission": "max_len:100",
|
||||
"type": "in:1,2,3",
|
||||
"status": "in:0,1",
|
||||
"is_hidden": "in:0,1",
|
||||
"link_type": "in:1,2",
|
||||
"open_type": "in:1,2",
|
||||
}
|
||||
|
||||
// 根据 link_type 动态设置 path 的验证规则
|
||||
linkType := ctx.Request().Input("link_type")
|
||||
if linkType == "2" {
|
||||
// 外部链接:需要验证为完整的 URL
|
||||
rules["path"] = "required|max_len:1000|full_url"
|
||||
} else {
|
||||
// 内部页面:只需要必填和长度验证
|
||||
rules["path"] = "required|max_len:1000"
|
||||
// 内部页面不验证 open_type
|
||||
delete(rules, "open_type")
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
func (r *MenuCreate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"title.required": trans.Get(ctx, "menu_title_required"),
|
||||
"title.max_len": trans.Get(ctx, "validation_title_max"),
|
||||
"slug.required": trans.Get(ctx, "menu_slug_required"),
|
||||
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
|
||||
"icon.max_len": trans.Get(ctx, "validation_icon_max"),
|
||||
"path.required": trans.Get(ctx, "menu_path_required"),
|
||||
"path.max_len": trans.Get(ctx, "validation_path_max"),
|
||||
"path.full_url": trans.Get(ctx, "validation_path_url_invalid"),
|
||||
"component.max_len": trans.Get(ctx, "validation_component_max"),
|
||||
"permission.max_len": trans.Get(ctx, "validation_permission_max"),
|
||||
"type.in": trans.Get(ctx, "validation_menu_type_in"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
"is_hidden.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MenuCreate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"title": trans.Get(ctx, "validation_title"),
|
||||
"slug": trans.Get(ctx, "validation_slug"),
|
||||
"icon": trans.Get(ctx, "validation_icon"),
|
||||
"path": trans.Get(ctx, "validation_path"),
|
||||
"component": trans.Get(ctx, "validation_component"),
|
||||
"permission": trans.Get(ctx, "validation_permission"),
|
||||
"type": trans.Get(ctx, "validation_type"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
"is_hidden": trans.Get(ctx, "validation_is_hidden"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MenuCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
// 将数字字段转换为字符串,以便 in 规则能正确验证
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "type"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "is_hidden"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "link_type"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "open_type"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type MenuUpdate struct {
|
||||
ParentID uint `form:"parent_id" json:"parent_id"`
|
||||
Title string `form:"title" json:"title"`
|
||||
Slug string `form:"slug" json:"slug"`
|
||||
Icon string `form:"icon" json:"icon"`
|
||||
Path string `form:"path" json:"path"`
|
||||
Component string `form:"component" json:"component"`
|
||||
Permission string `form:"permission" json:"permission"`
|
||||
Type uint8 `form:"type" json:"type"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
IsHidden uint8 `form:"is_hidden" json:"is_hidden"`
|
||||
LinkType uint8 `form:"link_type" json:"link_type"`
|
||||
OpenType uint8 `form:"open_type" json:"open_type"`
|
||||
}
|
||||
|
||||
func (r *MenuUpdate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MenuUpdate) Rules(ctx http.Context) map[string]string {
|
||||
rules := map[string]string{
|
||||
"title": "max_len:50",
|
||||
"slug": "max_len:50",
|
||||
"icon": "max_len:50",
|
||||
"component": "max_len:255",
|
||||
"permission": "max_len:100",
|
||||
"type": "in:1,2,3",
|
||||
"status": "in:0,1",
|
||||
"is_hidden": "in:0,1",
|
||||
"link_type": "in:1,2",
|
||||
"open_type": "in:1,2",
|
||||
}
|
||||
|
||||
// 根据 link_type 动态设置 path 的验证规则
|
||||
linkType := ctx.Request().Input("link_type")
|
||||
if linkType == "2" {
|
||||
// 外部链接:需要验证为完整的 URL
|
||||
rules["path"] = "max_len:1000|full_url"
|
||||
} else {
|
||||
// 内部页面:只需要长度验证
|
||||
rules["path"] = "max_len:1000"
|
||||
// 内部页面不验证 open_type
|
||||
delete(rules, "open_type")
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
func (r *MenuUpdate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"title.max_len": trans.Get(ctx, "validation_title_max"),
|
||||
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
|
||||
"icon.max_len": trans.Get(ctx, "validation_icon_max"),
|
||||
"path.max_len": trans.Get(ctx, "validation_path_max"),
|
||||
"path.full_url": trans.Get(ctx, "validation_path_url_invalid"),
|
||||
"component.max_len": trans.Get(ctx, "validation_component_max"),
|
||||
"permission.max_len": trans.Get(ctx, "validation_permission_max"),
|
||||
"type.in": trans.Get(ctx, "validation_menu_type_in"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
"is_hidden.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MenuUpdate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"title": trans.Get(ctx, "validation_title"),
|
||||
"slug": trans.Get(ctx, "validation_slug"),
|
||||
"icon": trans.Get(ctx, "validation_icon"),
|
||||
"path": trans.Get(ctx, "validation_path"),
|
||||
"component": trans.Get(ctx, "validation_component"),
|
||||
"permission": trans.Get(ctx, "validation_permission"),
|
||||
"type": trans.Get(ctx, "validation_type"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
"is_hidden": trans.Get(ctx, "validation_is_hidden"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MenuUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
// 将数字字段转换为字符串,以便 in 规则能正确验证
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "type"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "is_hidden"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "link_type"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := helpers.PrepareNumericFieldForValidation(data, "open_type"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
type PaymentMethodCreate struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Code string `form:"code" json:"code"`
|
||||
Type string `form:"type" json:"type"`
|
||||
Config map[string]any `form:"config" json:"config"`
|
||||
IsActive bool `form:"is_active" json:"is_active"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
Description string `form:"description" json:"description"`
|
||||
}
|
||||
|
||||
func (r *PaymentMethodCreate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PaymentMethodCreate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": "required|max_len:50",
|
||||
"code": "required|max_len:20",
|
||||
"type": "required|max_len:20",
|
||||
"config": "required",
|
||||
"is_active": "boolean",
|
||||
"sort": "min:0",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PaymentMethodCreate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name.required": trans.Get(ctx, "validation_name_required"),
|
||||
"name.max_len": trans.Get(ctx, "validation_name_max"),
|
||||
"code.required": trans.Get(ctx, "validation_code_required"),
|
||||
"code.max_len": trans.Get(ctx, "validation_code_max"),
|
||||
"type.required": trans.Get(ctx, "validation_type_required"),
|
||||
"type.max_len": trans.Get(ctx, "validation_type_max"),
|
||||
"config.required": trans.Get(ctx, "validation_config_required"),
|
||||
"is_active.boolean": trans.Get(ctx, "validation_boolean"),
|
||||
"sort.min": trans.Get(ctx, "validation_min"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PaymentMethodCreate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": trans.Get(ctx, "validation_name"),
|
||||
"code": trans.Get(ctx, "validation_code"),
|
||||
"type": trans.Get(ctx, "validation_type"),
|
||||
"config": trans.Get(ctx, "validation_config"),
|
||||
"is_active": trans.Get(ctx, "validation_is_active"),
|
||||
"sort": trans.Get(ctx, "validation_sort"),
|
||||
}
|
||||
}
|
||||
|
||||
// func (r *PaymentMethodCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
// // sort 字段使用 integer|min:0 规则,不需要转换为字符串
|
||||
// // 如果 sort 为空或不存在,设置为默认值 0
|
||||
// if val, exist := data.Get("sort"); !exist || val == nil || val == "" {
|
||||
// return data.Set("sort", 0)
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
@@ -0,0 +1,52 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
type PaymentMethodUpdate struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Config map[string]any `form:"config" json:"config"`
|
||||
IsActive bool `form:"is_active" json:"is_active"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
Description string `form:"description" json:"description"`
|
||||
}
|
||||
|
||||
func (r *PaymentMethodUpdate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PaymentMethodUpdate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": "required|max_len:50",
|
||||
"is_active": "boolean",
|
||||
"sort": "min:0",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PaymentMethodUpdate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name.required": trans.Get(ctx, "validation_name_required"),
|
||||
"name.max_len": trans.Get(ctx, "validation_name_max"),
|
||||
"is_active.boolean": trans.Get(ctx, "validation_boolean"),
|
||||
"sort.min": trans.Get(ctx, "validation_min"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PaymentMethodUpdate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": trans.Get(ctx, "validation_name"),
|
||||
"is_active": trans.Get(ctx, "validation_is_active"),
|
||||
"sort": trans.Get(ctx, "validation_sort"),
|
||||
}
|
||||
}
|
||||
|
||||
// func (r *PaymentMethodUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
// // return helpers.PrepareNumericFieldForValidation(data, "sort")
|
||||
// if val, exist := data.Get("sort"); !exist || val == nil || val == "" {
|
||||
// return data.Set("sort", 0)
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
@@ -0,0 +1,34 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
type ResetPassword struct {
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
func (r *ResetPassword) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ResetPassword) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"password": "required|min_len:6",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ResetPassword) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"password.required": trans.Get(ctx, "validation_password_required"),
|
||||
"password.min_len": trans.Get(ctx, "validation_password_min"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ResetPassword) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"password": trans.Get(ctx, "validation_password"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type RoleCreate struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Slug string `form:"slug" json:"slug"`
|
||||
Description string `form:"description" json:"description"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
}
|
||||
|
||||
func (r *RoleCreate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RoleCreate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": "required|max_len:50",
|
||||
"slug": "required|max_len:50",
|
||||
"description": "max_len:255",
|
||||
"status": "in:0,1",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RoleCreate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name.required": trans.Get(ctx, "validation_name_required"),
|
||||
"name.max_len": trans.Get(ctx, "validation_name_max"),
|
||||
"slug.required": trans.Get(ctx, "validation_slug_required"),
|
||||
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
|
||||
"description.max_len": trans.Get(ctx, "validation_description_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RoleCreate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": trans.Get(ctx, "validation_name"),
|
||||
"slug": trans.Get(ctx, "validation_slug"),
|
||||
"description": trans.Get(ctx, "validation_description"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RoleCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type RoleUpdate struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Slug string `form:"slug" json:"slug"`
|
||||
Description string `form:"description" json:"description"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
Sort int `form:"sort" json:"sort"`
|
||||
}
|
||||
|
||||
func (r *RoleUpdate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RoleUpdate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": "max_len:50",
|
||||
"slug": "max_len:50",
|
||||
"description": "max_len:255",
|
||||
"status": "in:0,1",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RoleUpdate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name.max_len": trans.Get(ctx, "validation_name_max"),
|
||||
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
|
||||
"description.max_len": trans.Get(ctx, "validation_description_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RoleUpdate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"name": trans.Get(ctx, "validation_name"),
|
||||
"slug": trans.Get(ctx, "validation_slug"),
|
||||
"description": trans.Get(ctx, "validation_description"),
|
||||
"status": trans.Get(ctx, "validation_status"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RoleUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
)
|
||||
|
||||
type UpdatePassword struct {
|
||||
OldPassword string `form:"old_password" json:"old_password"`
|
||||
NewPassword string `form:"new_password" json:"new_password"`
|
||||
ConfirmPassword string `form:"confirm_password" json:"confirm_password"`
|
||||
}
|
||||
|
||||
func (r *UpdatePassword) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UpdatePassword) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"old_password": "required",
|
||||
"new_password": "required|min_len:6",
|
||||
"confirm_password": "required|same:new_password",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UpdatePassword) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"old_password.required": trans.Get(ctx, "validation_old_password_required"),
|
||||
"new_password.required": trans.Get(ctx, "validation_new_password_required"),
|
||||
"new_password.min_len": trans.Get(ctx, "validation_password_min"),
|
||||
"confirm_password.required": trans.Get(ctx, "validation_confirm_password_required"),
|
||||
"confirm_password.same": trans.Get(ctx, "validation_password_not_match"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UpdatePassword) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"old_password": trans.Get(ctx, "validation_old_password"),
|
||||
"new_password": trans.Get(ctx, "validation_new_password"),
|
||||
"confirm_password": trans.Get(ctx, "validation_confirm_password"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type UserCreate struct {
|
||||
Username string `form:"username" json:"username"`
|
||||
Password string `form:"password" json:"password"`
|
||||
Nickname string `form:"nickname" json:"nickname"`
|
||||
Email string `form:"email" json:"email"`
|
||||
Phone string `form:"phone" json:"phone"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
}
|
||||
|
||||
func (r *UserCreate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserCreate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": "required|min_len:3|max_len:50|not_exists:users,username",
|
||||
"password": "required|min_len:6|max_len:50",
|
||||
"nickname": "max_len:50",
|
||||
"email": "email|max_len:100|not_exists:users,email",
|
||||
"phone": "max_len:20",
|
||||
"status": "in:0,1",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserCreate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username.required": trans.Get(ctx, "validation_username_required"),
|
||||
"username.min_len": trans.Get(ctx, "validation_username_min"),
|
||||
"username.max_len": trans.Get(ctx, "validation_username_max"),
|
||||
"username.not_exists": trans.Get(ctx, "username_exists"),
|
||||
"password.required": trans.Get(ctx, "validation_password_required"),
|
||||
"password.min_len": trans.Get(ctx, "validation_password_min"),
|
||||
"password.max_len": trans.Get(ctx, "validation_password_max"),
|
||||
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
|
||||
"email.email": trans.Get(ctx, "validation_email_format"),
|
||||
"email.max_len": trans.Get(ctx, "validation_email_max"),
|
||||
"email.not_exists": trans.Get(ctx, "email_already_exists"),
|
||||
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserCreate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": trans.Get(ctx, "attribute_username"),
|
||||
"password": trans.Get(ctx, "attribute_password"),
|
||||
"nickname": trans.Get(ctx, "attribute_nickname"),
|
||||
"email": trans.Get(ctx, "attribute_email"),
|
||||
"phone": trans.Get(ctx, "attribute_phone"),
|
||||
"status": trans.Get(ctx, "attribute_status"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type UserUpdate struct {
|
||||
Nickname string `form:"nickname" json:"nickname"`
|
||||
Email string `form:"email" json:"email"`
|
||||
Phone string `form:"phone" json:"phone"`
|
||||
Password string `form:"password" json:"password"`
|
||||
Status uint8 `form:"status" json:"status"`
|
||||
}
|
||||
|
||||
func (r *UserUpdate) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserUpdate) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"nickname": "max_len:50",
|
||||
"email": "email|max_len:100",
|
||||
"phone": "max_len:20",
|
||||
"password": "min_len:6|max_len:50",
|
||||
"status": "in:0,1",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserUpdate) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
|
||||
"email.email": trans.Get(ctx, "validation_email_format"),
|
||||
"email.max_len": trans.Get(ctx, "validation_email_max"),
|
||||
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
|
||||
"password.min_len": trans.Get(ctx, "validation_password_min"),
|
||||
"password.max_len": trans.Get(ctx, "validation_password_max"),
|
||||
"status.in": trans.Get(ctx, "validation_status_in"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserUpdate) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"nickname": trans.Get(ctx, "attribute_nickname"),
|
||||
"email": trans.Get(ctx, "attribute_email"),
|
||||
"phone": trans.Get(ctx, "attribute_phone"),
|
||||
"password": trans.Get(ctx, "attribute_password"),
|
||||
"status": trans.Get(ctx, "attribute_status"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return helpers.PrepareNumericFieldForValidation(data, "status")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type UserLogin struct {
|
||||
Username string `form:"username" json:"username"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
func (r *UserLogin) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserLogin) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": "required",
|
||||
"password": "required",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserLogin) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username.required": trans.Get(ctx, "validation_username_required"),
|
||||
"password.required": trans.Get(ctx, "validation_password_required"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserLogin) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": trans.Get(ctx, "attribute_username"),
|
||||
"password": trans.Get(ctx, "attribute_password"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserLogin) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"goravel/app/http/trans"
|
||||
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/contracts/validation"
|
||||
)
|
||||
|
||||
type UserRegister struct {
|
||||
Username string `form:"username" json:"username"`
|
||||
Password string `form:"password" json:"password"`
|
||||
Nickname string `form:"nickname" json:"nickname"`
|
||||
Email string `form:"email" json:"email"`
|
||||
Phone string `form:"phone" json:"phone"`
|
||||
}
|
||||
|
||||
func (r *UserRegister) Authorize(ctx http.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRegister) Rules(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": "required|min_len:3|max_len:50|not_exists:users,username",
|
||||
"password": "required|min_len:6|max_len:50",
|
||||
"nickname": "max_len:50",
|
||||
"email": "email|max_len:100|not_exists:users,email",
|
||||
"phone": "max_len:20",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserRegister) Messages(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username.required": trans.Get(ctx, "validation_username_required"),
|
||||
"username.min_len": trans.Get(ctx, "validation_username_min"),
|
||||
"username.max_len": trans.Get(ctx, "validation_username_max"),
|
||||
"username.not_exists": trans.Get(ctx, "username_exists"),
|
||||
"password.required": trans.Get(ctx, "validation_password_required"),
|
||||
"password.min_len": trans.Get(ctx, "validation_password_min"),
|
||||
"password.max_len": trans.Get(ctx, "validation_password_max"),
|
||||
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
|
||||
"email.email": trans.Get(ctx, "validation_email_format"),
|
||||
"email.max_len": trans.Get(ctx, "validation_email_max"),
|
||||
"email.not_exists": trans.Get(ctx, "email_already_exists"),
|
||||
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserRegister) Attributes(ctx http.Context) map[string]string {
|
||||
return map[string]string{
|
||||
"username": trans.Get(ctx, "attribute_username"),
|
||||
"password": trans.Get(ctx, "attribute_password"),
|
||||
"nickname": trans.Get(ctx, "attribute_nickname"),
|
||||
"email": trans.Get(ctx, "attribute_email"),
|
||||
"phone": trans.Get(ctx, "attribute_phone"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserRegister) PrepareForValidation(ctx http.Context, data validation.Data) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/goravel/framework/contracts/database/orm"
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
|
||||
apperrors "goravel/app/errors"
|
||||
"goravel/app/http/helpers"
|
||||
"goravel/app/http/trans"
|
||||
"goravel/app/services"
|
||||
"goravel/app/utils/errorlog"
|
||||
"goravel/app/utils/traceid"
|
||||
)
|
||||
|
||||
// 错误日志信息的 context key
|
||||
const (
|
||||
errorLogModuleKey = "error_log_module"
|
||||
errorLogMessageKey = "error_log_message"
|
||||
errorLogAttributesKey = "error_log_attributes"
|
||||
)
|
||||
|
||||
// Success 成功响应(支持多语言,自动包含 trace_id)
|
||||
// messageKey 可选,如果不传则使用默认值 "success"
|
||||
// 使用方式:
|
||||
// - response.Success(ctx)
|
||||
// - response.Success(ctx, data)
|
||||
// - response.Success(ctx, "custom_message", data)
|
||||
func Success(ctx http.Context, args ...any) http.Response {
|
||||
var messageKey string
|
||||
var data any
|
||||
|
||||
// 智能识别参数:如果第一个参数是 string 且长度合理(<=50),则认为是 messageKey
|
||||
if len(args) > 0 {
|
||||
if msgKey, ok := args[0].(string); ok && len(msgKey) <= 50 {
|
||||
// 方式1:传了 messageKey
|
||||
messageKey = msgKey
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
} else {
|
||||
// 方式2:没传 messageKey,第一个参数就是 data
|
||||
messageKey = "success" // 默认值
|
||||
data = args[0]
|
||||
}
|
||||
} else {
|
||||
// 没有参数,使用默认值
|
||||
messageKey = "success"
|
||||
}
|
||||
|
||||
message := trans.Get(ctx, messageKey)
|
||||
|
||||
response := http.Json{
|
||||
"code": 200,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
// 自动包含 trace_id,方便前端追踪
|
||||
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
|
||||
response["trace_id"] = traceID
|
||||
}
|
||||
|
||||
// 如果有数据,添加到响应中
|
||||
if data != nil {
|
||||
// 转换时间字段到对应时区
|
||||
convertedData := helpers.ConvertTimesInData(ctx, data)
|
||||
response["data"] = convertedData
|
||||
}
|
||||
return ctx.Response().Success().Json(response)
|
||||
}
|
||||
|
||||
// SuccessWithHeader 成功响应(支持多语言和自定义Header,自动包含 trace_id)
|
||||
func SuccessWithHeader(ctx http.Context, messageKey string, headerKey, headerValue string, data ...http.Json) http.Response {
|
||||
message := trans.Get(ctx, messageKey)
|
||||
|
||||
response := http.Json{
|
||||
"code": 200,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
// 自动包含 trace_id,方便前端追踪
|
||||
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
|
||||
response["trace_id"] = traceID
|
||||
}
|
||||
|
||||
if len(data) > 0 {
|
||||
// 转换时间字段到对应时区
|
||||
convertedData := helpers.ConvertTimesInData(ctx, data[0])
|
||||
if convertedMap, ok := convertedData.(map[string]any); ok {
|
||||
response["data"] = http.Json(convertedMap)
|
||||
} else {
|
||||
response["data"] = convertedData
|
||||
}
|
||||
}
|
||||
return ctx.Response().Header(headerKey, headerValue).Success().Json(response)
|
||||
}
|
||||
|
||||
// Error 错误响应(支持多语言,自动包含 trace_id 和 error_code)
|
||||
// 支持两种调用方式:
|
||||
// 1. response.Error(ctx, code, messageKey) - 使用翻译键
|
||||
// 2. response.Error(ctx, code, err) - 自动检测 BusinessError 并处理占位符替换
|
||||
//
|
||||
// 当 code >= 500 时,如果 context 中包含错误日志信息,会自动记录日志
|
||||
func Error(ctx http.Context, code int, messageOrErr any) http.Response {
|
||||
var message string
|
||||
var messageKey string
|
||||
|
||||
// 判断第二个参数是 error 还是 string
|
||||
switch v := messageOrErr.(type) {
|
||||
case error:
|
||||
// 如果是 error,检查是否是 BusinessError
|
||||
if businessErr, ok := apperrors.GetBusinessError(v); ok {
|
||||
// 使用 GetFormattedMessage 自动处理翻译和占位符替换
|
||||
message = businessErr.GetFormattedMessage(ctx)
|
||||
messageKey = businessErr.Code
|
||||
} else {
|
||||
// 普通错误,直接使用错误消息
|
||||
message = v.Error()
|
||||
messageKey = "operation_failed"
|
||||
}
|
||||
case string:
|
||||
// 如果是 string,当作翻译键处理
|
||||
messageKey = v
|
||||
message = trans.Get(ctx, messageKey)
|
||||
default:
|
||||
// 其他类型,转换为字符串
|
||||
messageKey = "operation_failed"
|
||||
message = trans.Get(ctx, messageKey)
|
||||
}
|
||||
|
||||
response := http.Json{
|
||||
"code": code,
|
||||
"message": message,
|
||||
"error_code": messageKey, // 添加错误码字段,方便前端判断
|
||||
}
|
||||
|
||||
// 自动包含 trace_id,方便前端显示和用户报告错误
|
||||
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
|
||||
response["trace_id"] = traceID
|
||||
}
|
||||
|
||||
// 系统级错误(500+)自动记录日志(如果 context 中有日志信息)
|
||||
if code >= 500 {
|
||||
if module, ok := ctx.Value(errorLogModuleKey).(string); ok && module != "" {
|
||||
logMessage := ctx.Value(errorLogMessageKey)
|
||||
if logMsg, ok := logMessage.(string); ok && logMsg != "" {
|
||||
attributes := ctx.Value(errorLogAttributesKey)
|
||||
var attrs map[string]any
|
||||
if attributes != nil {
|
||||
if attrsMap, ok := attributes.(map[string]any); ok {
|
||||
attrs = attrsMap
|
||||
}
|
||||
}
|
||||
errorlog.RecordHTTP(ctx, module, logMsg, attrs, "%s: %s", module, logMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.Response().Json(code, response)
|
||||
}
|
||||
|
||||
// SetErrorLog 设置错误日志信息到 context(用于系统级错误)
|
||||
// 在调用 response.Error 之前调用此函数,Error 函数会自动记录日志
|
||||
func SetErrorLog(ctx http.Context, module, logMessage string, attributes map[string]any) {
|
||||
ctx.WithValue(errorLogModuleKey, module)
|
||||
ctx.WithValue(errorLogMessageKey, logMessage)
|
||||
if attributes != nil {
|
||||
ctx.WithValue(errorLogAttributesKey, attributes)
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorWithLog 错误响应并自动记录日志(用于系统级错误)
|
||||
// 支持多种调用方式,自动推断参数:
|
||||
//
|
||||
// 最简洁方式(推荐):
|
||||
// - response.ErrorWithLog(ctx, "module", err)
|
||||
// - response.ErrorWithLog(ctx, "module", err, map[string]any{"extra": "value"})
|
||||
//
|
||||
// 完整方式:
|
||||
// - response.ErrorWithLog(ctx, code, messageKey, module, logMessage, err)
|
||||
// - response.ErrorWithLog(ctx, code, messageKey, module, logMessage, err, map[string]any{...})
|
||||
func ErrorWithLog(ctx http.Context, args ...any) http.Response {
|
||||
// 智能识别调用方式
|
||||
if len(args) == 0 {
|
||||
return Error(ctx, http.StatusInternalServerError, "operation_failed")
|
||||
}
|
||||
|
||||
// 方式1:最简洁方式 - ErrorWithLog(ctx, "module", err) 或 ErrorWithLog(ctx, "module", err, attrs)
|
||||
if len(args) >= 2 {
|
||||
if module, ok := args[0].(string); ok {
|
||||
var err error
|
||||
var attributes map[string]any
|
||||
|
||||
// 查找 error 和 attributes
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch v := args[i].(type) {
|
||||
case error:
|
||||
if err == nil {
|
||||
err = v
|
||||
}
|
||||
case map[string]any:
|
||||
if attributes == nil {
|
||||
attributes = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return Error(ctx, http.StatusInternalServerError, "operation_failed")
|
||||
}
|
||||
|
||||
// 自动生成日志消息
|
||||
logMessage := err.Error()
|
||||
if len(logMessage) > 100 {
|
||||
logMessage = logMessage[:100] + "..."
|
||||
}
|
||||
|
||||
// 设置属性
|
||||
if attributes == nil {
|
||||
attributes = make(map[string]any)
|
||||
}
|
||||
if _, exists := attributes["error"]; !exists {
|
||||
attributes["error"] = err.Error()
|
||||
}
|
||||
|
||||
// 自动记录日志
|
||||
SetErrorLog(ctx, module, logMessage, attributes)
|
||||
return Error(ctx, http.StatusInternalServerError, "operation_failed")
|
||||
}
|
||||
}
|
||||
|
||||
// 方式2:完整方式 - ErrorWithLog(ctx, code, messageKey, module, logMessage, ...)
|
||||
if len(args) >= 4 {
|
||||
code, codeOk := args[0].(int)
|
||||
messageKey, msgOk := args[1].(string)
|
||||
module, modOk := args[2].(string)
|
||||
logMessage, logOk := args[3].(string)
|
||||
|
||||
if codeOk && msgOk && modOk && logOk {
|
||||
var attributes map[string]any
|
||||
var err error
|
||||
|
||||
// 解析剩余参数
|
||||
for i := 4; i < len(args); i++ {
|
||||
switch v := args[i].(type) {
|
||||
case error:
|
||||
if err == nil {
|
||||
err = v
|
||||
}
|
||||
case map[string]any:
|
||||
if attributes == nil {
|
||||
attributes = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置属性
|
||||
if attributes == nil {
|
||||
attributes = make(map[string]any)
|
||||
}
|
||||
if err != nil {
|
||||
if _, exists := attributes["error"]; !exists {
|
||||
attributes["error"] = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// 系统级错误(500+)设置日志信息,Error 函数会自动记录
|
||||
if code >= 500 {
|
||||
SetErrorLog(ctx, module, logMessage, attributes)
|
||||
}
|
||||
return Error(ctx, code, messageKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 无法识别参数格式,返回通用错误
|
||||
return Error(ctx, http.StatusInternalServerError, "operation_failed")
|
||||
}
|
||||
|
||||
// ErrorWithLogAuto 超简洁版本:自动推断所有参数
|
||||
// 只需要传入 module 和 err,其他参数自动推断
|
||||
// 默认使用 HTTP 500 状态码和通用的错误消息
|
||||
//
|
||||
// 使用方式:
|
||||
// - response.ErrorWithLogAuto(ctx, "operation-log", err)
|
||||
// - response.ErrorWithLogAuto(ctx, "operation-log", err, map[string]any{"extra": "value"})
|
||||
func ErrorWithLogAuto(ctx http.Context, module string, args ...any) http.Response {
|
||||
var err error
|
||||
var attributes map[string]any
|
||||
|
||||
// 解析参数
|
||||
for i := len(args) - 1; i >= 0; i-- {
|
||||
switch v := args[i].(type) {
|
||||
case error:
|
||||
if err == nil {
|
||||
err = v
|
||||
}
|
||||
case map[string]any:
|
||||
if attributes == nil {
|
||||
attributes = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有 error,返回通用错误
|
||||
if err == nil {
|
||||
return Error(ctx, http.StatusInternalServerError, "operation_failed")
|
||||
}
|
||||
|
||||
// 如果没有提供 attributes,创建一个
|
||||
if attributes == nil {
|
||||
attributes = make(map[string]any)
|
||||
}
|
||||
|
||||
// 自动添加 error 字段
|
||||
if _, exists := attributes["error"]; !exists {
|
||||
attributes["error"] = err.Error()
|
||||
}
|
||||
|
||||
// 自动生成日志消息:使用 err.Error() 作为日志消息
|
||||
logMessage := err.Error()
|
||||
// 如果错误信息太长,截取前100个字符
|
||||
if len(logMessage) > 100 {
|
||||
logMessage = logMessage[:100] + "..."
|
||||
}
|
||||
|
||||
// 自动推断 messageKey:根据 module 生成通用的错误消息键
|
||||
messageKey := "operation_failed"
|
||||
if module != "" {
|
||||
// 尝试使用 module 相关的错误消息键
|
||||
// 例如 "operation-log" -> "operation_failed"
|
||||
// 或者保持通用
|
||||
messageKey = "operation_failed"
|
||||
}
|
||||
|
||||
// 系统级错误(500)自动记录日志
|
||||
SetErrorLog(ctx, module, logMessage, attributes)
|
||||
return Error(ctx, http.StatusInternalServerError, messageKey)
|
||||
}
|
||||
|
||||
// ValidationError 验证错误响应(支持多语言,自动包含 trace_id 和 error_code)
|
||||
// 自动提取第一个错误信息并添加到 message 中,方便前端直接显示
|
||||
func ValidationError(ctx http.Context, code int, messageKey string, errors map[string]map[string]string) http.Response {
|
||||
baseMessage := trans.Get(ctx, messageKey)
|
||||
|
||||
// 提取第一个错误信息
|
||||
var firstError string
|
||||
for _, fieldErrors := range errors {
|
||||
for _, errorMsg := range fieldErrors {
|
||||
firstError = errorMsg
|
||||
break
|
||||
}
|
||||
if firstError != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有具体错误信息,将其添加到 message 中
|
||||
var message string
|
||||
if firstError != "" {
|
||||
message = firstError
|
||||
} else {
|
||||
message = baseMessage
|
||||
}
|
||||
|
||||
response := http.Json{
|
||||
"code": code,
|
||||
"message": message,
|
||||
"error_code": messageKey, // 添加错误码字段,方便前端判断
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
// 自动包含 trace_id,方便前端显示和用户报告错误
|
||||
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
|
||||
response["trace_id"] = traceID
|
||||
}
|
||||
|
||||
return ctx.Response().Json(code, response)
|
||||
}
|
||||
|
||||
// Paginate 分页响应(支持多语言,自动包含 trace_id)
|
||||
// messageKey 可选,如果不传则使用默认值 "get_success"
|
||||
// 使用方式:
|
||||
// - response.Paginate(ctx, list, total, page, pageSize)
|
||||
// - response.Paginate(ctx, "custom_message", list, total, page, pageSize)
|
||||
func Paginate(ctx http.Context, args ...any) http.Response {
|
||||
var messageKey string
|
||||
var list any
|
||||
var total int64
|
||||
var page, pageSize int
|
||||
|
||||
// 智能识别参数:如果第一个参数是 string 且长度合理(<=50),则认为是 messageKey
|
||||
if len(args) >= 4 {
|
||||
if msgKey, ok := args[0].(string); ok && len(msgKey) <= 50 {
|
||||
// 方式1:传了 messageKey
|
||||
messageKey = msgKey
|
||||
list = args[1]
|
||||
if t, ok := args[2].(int64); ok {
|
||||
total = t
|
||||
} else if t, ok := args[2].(int); ok {
|
||||
total = int64(t)
|
||||
}
|
||||
if p, ok := args[3].(int); ok {
|
||||
page = p
|
||||
}
|
||||
if len(args) >= 5 {
|
||||
if ps, ok := args[4].(int); ok {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 方式2:没传 messageKey
|
||||
messageKey = "get_success" // 默认值
|
||||
list = args[0]
|
||||
if t, ok := args[1].(int64); ok {
|
||||
total = t
|
||||
} else if t, ok := args[1].(int); ok {
|
||||
total = int64(t)
|
||||
}
|
||||
if p, ok := args[2].(int); ok {
|
||||
page = p
|
||||
}
|
||||
if len(args) >= 4 {
|
||||
if ps, ok := args[3].(int); ok {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 messageKey 为空,使用默认值
|
||||
if messageKey == "" {
|
||||
messageKey = "get_success"
|
||||
}
|
||||
|
||||
message := trans.Get(ctx, messageKey)
|
||||
|
||||
// 转换列表中的时间字段到对应时区
|
||||
convertedList := helpers.ConvertTimesInData(ctx, list)
|
||||
|
||||
response := http.Json{
|
||||
"code": 200,
|
||||
"message": message,
|
||||
"data": http.Json{
|
||||
"list": convertedList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
},
|
||||
}
|
||||
|
||||
// 自动包含 trace_id,方便前端追踪
|
||||
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
|
||||
response["trace_id"] = traceID
|
||||
}
|
||||
|
||||
return ctx.Response().Success().Json(response)
|
||||
}
|
||||
|
||||
// PaginateQueryOptions 分页查询选项
|
||||
type PaginateQueryOptions struct {
|
||||
// WithRelations 预加载关联,例如 []string{"Department", "Roles"}
|
||||
WithRelations []string
|
||||
// Transform 数据转换函数,可以对查询结果进行转换
|
||||
Transform func(any) any
|
||||
// ErrorHandler 自定义错误处理函数,如果为 nil 则使用默认错误处理
|
||||
ErrorHandler func(ctx http.Context, err error, module string) http.Response
|
||||
// ErrorModule 错误日志模块名,用于 ErrorWithLog
|
||||
ErrorModule string
|
||||
}
|
||||
|
||||
// PaginateQuery 通用的分页查询封装
|
||||
// query: 构建好的查询对象(已包含所有查询条件)
|
||||
// list: 用于接收查询结果的切片指针,例如 &[]models.Dictionary{}
|
||||
// options: 可选配置
|
||||
//
|
||||
// 使用示例:
|
||||
//
|
||||
// - 基础用法:
|
||||
// return response.PaginateQuery(ctx, query, &dictionaries, nil)
|
||||
//
|
||||
// - 带预加载关联:
|
||||
// return response.PaginateQuery(ctx, query, &roles, &response.PaginateQueryOptions{
|
||||
// WithRelations: []string{"Permissions", "Menus"},
|
||||
// })
|
||||
//
|
||||
// - 带数据转换:
|
||||
// return response.PaginateQuery(ctx, query, &admins, &response.PaginateQueryOptions{
|
||||
// WithRelations: []string{"Department", "Roles"},
|
||||
// Transform: func(data any) any {
|
||||
// admins := data.(*[]models.Admin)
|
||||
// // 转换逻辑
|
||||
// return adminList
|
||||
// },
|
||||
// })
|
||||
//
|
||||
// - 带错误日志模块:
|
||||
// return response.PaginateQuery(ctx, query, &logs, &response.PaginateQueryOptions{
|
||||
// WithRelations: []string{"Admin"},
|
||||
// ErrorModule: "login-log",
|
||||
// })
|
||||
func PaginateQuery(ctx http.Context, query orm.Query, list any, options *PaginateQueryOptions) http.Response {
|
||||
// 获取分页参数
|
||||
page := helpers.GetIntQuery(ctx, "page", 1)
|
||||
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
|
||||
page, pageSize = helpers.ValidatePagination(page, pageSize)
|
||||
|
||||
// 获取总数
|
||||
total, err := query.Count()
|
||||
if err != nil {
|
||||
if options != nil && options.ErrorHandler != nil {
|
||||
return options.ErrorHandler(ctx, err, options.ErrorModule)
|
||||
}
|
||||
if options != nil && options.ErrorModule != "" {
|
||||
return ErrorWithLog(ctx, options.ErrorModule, err)
|
||||
}
|
||||
return Error(ctx, http.StatusInternalServerError, "query_failed")
|
||||
}
|
||||
|
||||
// 应用预加载关联
|
||||
if options != nil && len(options.WithRelations) > 0 {
|
||||
for _, relation := range options.WithRelations {
|
||||
query = query.With(relation)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Offset(offset).Limit(pageSize).Get(list); err != nil {
|
||||
if options != nil && options.ErrorHandler != nil {
|
||||
return options.ErrorHandler(ctx, err, options.ErrorModule)
|
||||
}
|
||||
if options != nil && options.ErrorModule != "" {
|
||||
return ErrorWithLog(ctx, options.ErrorModule, err)
|
||||
}
|
||||
return Error(ctx, http.StatusInternalServerError, "query_failed")
|
||||
}
|
||||
|
||||
// 数据转换
|
||||
var result any = list
|
||||
if options != nil && options.Transform != nil {
|
||||
result = options.Transform(list)
|
||||
}
|
||||
|
||||
return Paginate(ctx, result, total, page, pageSize)
|
||||
}
|
||||
|
||||
// Export 导出响应(支持多语言)
|
||||
// headers: CSV表头(可以是翻译键数组或字符串数组)
|
||||
// data: 数据行,每行是一个字符串切片
|
||||
// filename: 文件名(不含扩展名)
|
||||
func Export(ctx http.Context, messageKey string, headers []string, data [][]string, filename string) http.Response {
|
||||
message := trans.Get(ctx, messageKey)
|
||||
|
||||
// 翻译表头(如果表头是翻译键,则翻译;如果是普通字符串,则保持原样)
|
||||
translatedHeaders := make([]string, len(headers))
|
||||
for i, header := range headers {
|
||||
// 尝试翻译,如果翻译键不存在则返回原字符串
|
||||
translated := trans.Get(ctx, header)
|
||||
if translated == header {
|
||||
// 如果翻译结果和原字符串相同,说明不是翻译键,直接使用原字符串
|
||||
translatedHeaders[i] = header
|
||||
} else {
|
||||
// 如果翻译成功,使用翻译后的文本
|
||||
translatedHeaders[i] = translated
|
||||
}
|
||||
}
|
||||
|
||||
exportService := services.NewExportService(ctx)
|
||||
filePath, err := exportService.ExportToFile(translatedHeaders, data, filename)
|
||||
if err != nil {
|
||||
return Error(ctx, http.StatusInternalServerError, "export_failed")
|
||||
}
|
||||
|
||||
exportURL := exportService.GetExportURL(filePath)
|
||||
|
||||
response := http.Json{
|
||||
"code": 200,
|
||||
"message": message,
|
||||
"data": http.Json{
|
||||
"file_path": filePath,
|
||||
"file_url": exportURL,
|
||||
},
|
||||
}
|
||||
|
||||
// 自动包含 trace_id,方便前端追踪
|
||||
if traceID := traceid.FromHTTPContext(ctx); traceID != "" {
|
||||
response["trace_id"] = traceID
|
||||
}
|
||||
|
||||
return ctx.Response().Success().Json(response)
|
||||
}
|
||||
|
||||
// FindByIDOptions 查找选项
|
||||
type FindByIDOptions struct {
|
||||
// WithRelations 预加载关联,例如 []string{"Department", "Roles"}
|
||||
WithRelations []string
|
||||
// NotFoundMessageKey 未找到时的错误消息键,例如 "admin_not_found"
|
||||
// 如果不提供,默认使用 "record_not_found"
|
||||
NotFoundMessageKey string
|
||||
}
|
||||
|
||||
// FindByID 通用的根据ID查找记录函数
|
||||
// T 必须是嵌入了 orm.Model 的模型类型
|
||||
// 使用示例:
|
||||
//
|
||||
// // 简单查找
|
||||
// admin, resp := response.FindByID[models.Admin](ctx, id, nil)
|
||||
// if resp != nil {
|
||||
// return resp
|
||||
// }
|
||||
//
|
||||
// // 带关联预加载
|
||||
// admin, resp := response.FindByID[models.Admin](ctx, id, &response.FindByIDOptions{
|
||||
// WithRelations: []string{"Department", "Roles"},
|
||||
// NotFoundMessageKey: "admin_not_found",
|
||||
// })
|
||||
// if resp != nil {
|
||||
// return resp
|
||||
// }
|
||||
func FindByID[T any](ctx http.Context, id uint, options *FindByIDOptions) (*T, http.Response) {
|
||||
// 验证ID
|
||||
if id == 0 {
|
||||
return nil, Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
|
||||
}
|
||||
|
||||
// 创建查询
|
||||
query := facades.Orm().Query().Where("id", id)
|
||||
|
||||
// 应用关联预加载
|
||||
if options != nil && len(options.WithRelations) > 0 {
|
||||
for _, relation := range options.WithRelations {
|
||||
query = query.With(relation)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询记录
|
||||
var model T
|
||||
if err := query.First(&model); err != nil {
|
||||
// 确定错误消息键,默认使用 record_not_found
|
||||
messageKey := apperrors.ErrRecordNotFound.Code
|
||||
if options != nil && options.NotFoundMessageKey != "" {
|
||||
messageKey = options.NotFoundMessageKey
|
||||
}
|
||||
return nil, Error(ctx, http.StatusNotFound, messageKey)
|
||||
}
|
||||
|
||||
// 检查记录是否存在(防御性编程)
|
||||
// 通过反射检查 ID 字段,如果 ID 为 0,说明记录不存在
|
||||
modelPtr := &model
|
||||
if !hasValidID(modelPtr) {
|
||||
// 确定错误消息键,默认使用 record_not_found
|
||||
messageKey := apperrors.ErrRecordNotFound.Code
|
||||
if options != nil && options.NotFoundMessageKey != "" {
|
||||
messageKey = options.NotFoundMessageKey
|
||||
}
|
||||
return nil, Error(ctx, http.StatusNotFound, messageKey)
|
||||
}
|
||||
|
||||
return modelPtr, nil
|
||||
}
|
||||
|
||||
// hasValidID 检查模型是否有有效的 ID(ID != 0)
|
||||
// 使用反射来检查模型的 ID 字段
|
||||
func hasValidID(model any) bool {
|
||||
v := reflect.ValueOf(model)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
// 查找 ID 字段
|
||||
idField := v.FieldByName("ID")
|
||||
if !idField.IsValid() {
|
||||
// 如果没有找到 ID 字段,假设记录有效(防御性编程)
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查 ID 是否为 0
|
||||
if idField.Kind() == reflect.Uint || idField.Kind() == reflect.Uint32 || idField.Kind() == reflect.Uint64 {
|
||||
return idField.Uint() != 0
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package trans
|
||||
|
||||
import (
|
||||
"github.com/goravel/framework/contracts/http"
|
||||
"github.com/goravel/framework/facades"
|
||||
)
|
||||
|
||||
// Get 获取翻译文本(支持多语言)
|
||||
// 自动尝试 messages. 前缀的翻译键
|
||||
func Get(ctx http.Context, key string) string {
|
||||
// 如果key已经包含 messages. 前缀,直接使用
|
||||
if len(key) > 8 && key[:8] == "messages." {
|
||||
message := facades.Lang(ctx).Get(key)
|
||||
// 如果返回的键和输入的键相同或为空,说明没找到
|
||||
if message != key && message != "" {
|
||||
return message
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// 尝试使用 messages. 前缀的key(这是语言文件中的实际格式)
|
||||
messageKey := "messages." + key
|
||||
message := facades.Lang(ctx).Get(messageKey)
|
||||
// 如果返回的键和输入的键不同且不为空,说明找到了
|
||||
if message != messageKey && message != "" {
|
||||
return message
|
||||
}
|
||||
|
||||
// 如果带前缀的找不到,尝试直接获取(某些键可能不在messages下)
|
||||
message = facades.Lang(ctx).Get(key)
|
||||
if message != key && message != "" {
|
||||
return message
|
||||
}
|
||||
|
||||
// 如果还是不存在,返回原始key
|
||||
return key
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user