初始化
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
// 管理后台接口处理器,负责解析管理端请求并调用对应业务模块组织响应。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mediakey "juhe-factory/api/internal/media"
|
||||
adminmodule "juhe-factory/api/internal/modules/admin"
|
||||
"juhe-factory/api/internal/modules/prompt"
|
||||
"juhe-factory/api/internal/service"
|
||||
"juhe-factory/api/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminData struct {
|
||||
data *service.AdminData
|
||||
admin *adminmodule.Service
|
||||
prompts *prompt.Service
|
||||
cos *storage.COS
|
||||
}
|
||||
|
||||
type optionalTime struct{ Time *time.Time }
|
||||
|
||||
func (t *optionalTime) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" || strings.TrimSpace(string(data)) == `""` {
|
||||
t.Time = nil
|
||||
return nil
|
||||
}
|
||||
var value time.Time
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
t.Time = &value
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewAdminData(data *service.AdminData, admin *adminmodule.Service, prompts *prompt.Service, cos *storage.COS) *AdminData {
|
||||
return &AdminData{data: data, admin: admin, prompts: prompts, cos: cos}
|
||||
}
|
||||
|
||||
func (h *AdminData) ListResource(c *gin.Context) {
|
||||
filters := map[string]string{"enabled": c.Query("enabled"), "payment_type": c.Query("payment_type"), "channel_type": c.Query("channel_type"), "status": c.Query("status"), "pinned": c.Query("pinned")}
|
||||
page, err := h.data.ListResource(c.Request.Context(), c.Param("resource"), c.Query("keyword"), filters, c.Query("page"), c.Query("page_size"))
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": page})
|
||||
}
|
||||
|
||||
func (h *AdminData) CreateResource(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
id, err := h.data.SaveResource(c.Param("resource"), "", body)
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "create", c.Param("resource"), id, "", body)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": gin.H{"id": id}})
|
||||
}
|
||||
func (h *AdminData) UpdateResource(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
id, err := h.data.SaveResource(c.Param("resource"), c.Param("id"), body)
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "update", c.Param("resource"), id, stringValue(body["reason"]), body)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": id}})
|
||||
}
|
||||
func (h *AdminData) ToggleResource(c *gin.Context) {
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.data.ToggleResource(c.Param("resource"), c.Param("id"), body.Enabled); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "toggle", c.Param("resource"), c.Param("id"), body.Reason, body)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
func (h *AdminData) DeleteResource(c *gin.Context) {
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if err := h.data.DeleteResource(c.Param("resource"), c.Param("id")); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "delete", c.Param("resource"), c.Param("id"), body.Reason, nil)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AdminData) ReorderStyles(c *gin.Context) {
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.data.ReorderStyles(body.IDs); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "reorder", "styles", strings.Join(body.IDs, ","), "", map[string]any{"count": len(body.IDs)})
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AdminData) ListUsers(c *gin.Context) {
|
||||
page, err := h.data.ListUsers(c.Query("keyword"), c.Query("enabled"), c.Query("page"), c.Query("page_size"))
|
||||
if err != nil {
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": page})
|
||||
}
|
||||
|
||||
// ListAuditLogs 返回管理端操作审计记录,不提供修改或删除能力。
|
||||
func (h *AdminData) ListAuditLogs(c *gin.Context) {
|
||||
page, size := parsePage(c)
|
||||
result, err := h.admin.ListAuditLogs(c.Query("keyword"), page, size)
|
||||
if err != nil {
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminData) CreateUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
DailyLimit any `json:"daily_limit"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
item, err := h.data.CreateUser(body.Account, body.Password, body.DailyLimit)
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "create", "users", stringValue(item["id"]), "", map[string]any{"account": item["account"], "username": item["username"], "daily_limit": body.DailyLimit})
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *AdminData) BatchCreateUsers(c *gin.Context) {
|
||||
var body struct {
|
||||
Prefix string `json:"prefix"`
|
||||
StartSequence string `json:"start_sequence"`
|
||||
EndSequence string `json:"end_sequence"`
|
||||
Password string `json:"password"`
|
||||
DailyLimit any `json:"daily_limit"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.data.BatchCreateUsers(body.Prefix, body.StartSequence, body.EndSequence, body.Password, body.DailyLimit)
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "batch_create", "users", "", "", map[string]any{"prefix": body.Prefix, "start_sequence": body.StartSequence, "end_sequence": body.EndSequence, "count": len(items)})
|
||||
c.JSON(http.StatusCreated, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminData) BatchUpdateUsers(c *gin.Context) {
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
DailyLimit any `json:"daily_limit"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if body.DailyLimit != nil {
|
||||
updates["daily_limit"] = body.DailyLimit
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
updates["enabled"] = *body.Enabled
|
||||
}
|
||||
if err := h.data.UpdateUsers(body.IDs, updates); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "batch_update", "users", strings.Join(body.IDs, ","), body.Reason, updates)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AdminData) BatchDeleteUsers(c *gin.Context) {
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.data.DeleteUsers(body.IDs); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "batch_delete", "users", strings.Join(body.IDs, ","), body.Reason, nil)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GrantUserPoints 校验管理端积分发放请求,完成入账后记录操作审计。
|
||||
func (h *AdminData) GrantUserPoints(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
h.bad(c, errors.New("用户标识无效"))
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Points any `json:"points"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
body.Reason = strings.TrimSpace(body.Reason)
|
||||
if body.Reason == "" {
|
||||
h.bad(c, errors.New("请填写积分发放原因"))
|
||||
return
|
||||
}
|
||||
if len([]rune(body.Reason)) > 200 {
|
||||
h.bad(c, errors.New("积分发放原因不能超过 200 个字符"))
|
||||
return
|
||||
}
|
||||
balance, credited, err := h.admin.GrantUserPoints(userID, body.RequestID, body.Points)
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if credited {
|
||||
h.audit(c, "grant_points", "users", userID.String(), body.Reason, map[string]any{
|
||||
"points": body.Points, "balance": balance, "request_id": body.RequestID,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"balance": balance, "credited": credited}})
|
||||
}
|
||||
|
||||
func (h *AdminData) ListModels(c *gin.Context) {
|
||||
page, size := parsePage(c)
|
||||
result, err := h.admin.ListModels(c.Query("keyword"), c.Query("model_type"), c.Query("channel_id"), page, size)
|
||||
if err != nil {
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminData) SaveModel(c *gin.Context) {
|
||||
var body adminmodule.ModelInput
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
id, created, normalizedBody, err := h.admin.SaveModel(c.Param("id"), body)
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
action := "update"
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
action = "create"
|
||||
status = http.StatusCreated
|
||||
}
|
||||
h.audit(c, action, "models", id, "", normalizedBody)
|
||||
c.JSON(status, gin.H{"data": gin.H{"id": id}})
|
||||
}
|
||||
|
||||
func (h *AdminData) ListPrompts(c *gin.Context) {
|
||||
page, size := parsePage(c)
|
||||
items, total, err := h.prompts.ListSystemPrompts(c.Query("keyword"), c.Query("type"), page, size)
|
||||
if err != nil {
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": service.Page{Items: items, Total: total, Page: page, PageSize: size}})
|
||||
}
|
||||
|
||||
func (h *AdminData) SavePrompt(c *gin.Context) {
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Variables []string `json:"variables"`
|
||||
VersionNote string `json:"version_note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Category) == "" {
|
||||
body.Category = body.Type
|
||||
}
|
||||
if strings.TrimSpace(body.Type) == "" {
|
||||
body.Type = body.Category
|
||||
}
|
||||
if err := service.ValidatePromptVariables(body.Content, body.Variables); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
id, err := h.prompts.SaveVersionedPrompt(currentAdmin(c).ID, c.Param("id"), prompt.VersionedPromptInput{
|
||||
Code: body.Code, Name: body.Name, Category: body.Category, Type: body.Type,
|
||||
Content: body.Content, Variables: body.Variables, VersionNote: body.VersionNote,
|
||||
})
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "save_draft", "prompts", id, body.VersionNote, nil)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": id}})
|
||||
}
|
||||
|
||||
// SavePromptSimple 保存系统提示词并立即应用,不启用历史版本工作流。
|
||||
func (h *AdminData) SavePromptSimple(c *gin.Context) {
|
||||
var body prompt.SystemPromptInput
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
item, err := h.prompts.SaveSystemPrompt(c.Param("id"), body)
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "save", "prompts", item.ID, "", body)
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
// DeletePromptSimple 删除管理端指定的系统提示词。
|
||||
func (h *AdminData) DeletePromptSimple(c *gin.Context) {
|
||||
if err := h.prompts.DeleteSystemPrompt(c.Param("id")); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AdminData) PromptAction(c *gin.Context) {
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
VersionID string `json:"version_id"`
|
||||
VersionNote string `json:"version_note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := h.prompts.ApplyPromptAction(currentAdmin(c).ID, id, body.Action, body.VersionID, body.VersionNote); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, body.Action, "prompts", id, body.VersionNote, body)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AdminData) PromptHistory(c *gin.Context) {
|
||||
items, err := h.prompts.ListPromptHistory(c.Param("id"))
|
||||
if err != nil {
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminData) PromptActionSimple(c *gin.Context) { c.Status(http.StatusNoContent) }
|
||||
func (h *AdminData) PromptHistorySimple(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": []any{}})
|
||||
}
|
||||
|
||||
func (h *AdminData) ListRedemptions(c *gin.Context) {
|
||||
page, size := parsePage(c)
|
||||
result, err := h.admin.ListRedemptions(c.Query("keyword"), c.Query("status"), page, size)
|
||||
if err != nil {
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminData) CreateRedemptionBatch(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Points any `json:"points"`
|
||||
Quantity int `json:"quantity"`
|
||||
ExpiresAt optionalTime `json:"expires_at"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if body.ExpiresAt.Time == nil {
|
||||
expiresAt := time.Now().AddDate(0, 0, 7)
|
||||
body.ExpiresAt.Time = &expiresAt
|
||||
}
|
||||
admin := currentAdmin(c)
|
||||
batchID, codes, points, err := h.admin.CreateRedemptionBatch(admin.ID, body.Name, body.Points, body.Quantity, *body.ExpiresAt.Time)
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, "generate", "redemption-codes", batchID, "", map[string]any{"quantity": body.Quantity, "points": points})
|
||||
c.JSON(http.StatusCreated, gin.H{"data": gin.H{"batch_id": batchID, "codes": codes}})
|
||||
}
|
||||
|
||||
// validateRedemptionBatchLimits 保留 Handler 包内既有测试入口,实际规则由管理后台模块统一实现。
|
||||
func validateRedemptionBatchLimits(value any, quantity int) (string, error) {
|
||||
return adminmodule.ValidateRedemptionBatchLimits(value, quantity)
|
||||
}
|
||||
|
||||
func (h *AdminData) UploadStyleImage(c *gin.Context) {
|
||||
if h.cos == nil {
|
||||
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置")
|
||||
return
|
||||
}
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
h.bad(c, errors.New("请选择图片文件"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if header.Size <= 0 || header.Size > h.cos.MaxImageBytes() {
|
||||
h.bad(c, errors.New("图片大小超出限制"))
|
||||
return
|
||||
}
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/gif" {
|
||||
h.bad(c, errors.New("仅支持 JPEG、PNG 或 GIF 图片"))
|
||||
return
|
||||
}
|
||||
width, height, err := imageDimensions(file, contentType)
|
||||
if err != nil {
|
||||
h.bad(c, errors.New("无法识别图片内容"))
|
||||
return
|
||||
}
|
||||
if width < 64 || height < 64 || width > 8192 || height > 8192 {
|
||||
h.bad(c, errors.New("图片尺寸必须在 64×64 至 8192×8192 之间"))
|
||||
return
|
||||
}
|
||||
if _, err = file.Seek(0, 0); err != nil {
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext == "" {
|
||||
ext = ".img"
|
||||
}
|
||||
key := mediakey.StyleImage(uuid.New(), ext)
|
||||
url, err := h.cos.Put(c, key, contentType, file, header.Size)
|
||||
if err != nil {
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": gin.H{"image_key": key, "image_url": url, "image_mime": contentType, "image_size": header.Size, "image_width": width, "image_height": height}})
|
||||
}
|
||||
|
||||
func imageDimensions(file multipart.File, contentType string) (int, int, error) {
|
||||
if contentType == "image/webp" {
|
||||
return 0, 0, errors.New("WebP 尺寸解析暂不可用")
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(file)
|
||||
return cfg.Width, cfg.Height, err
|
||||
}
|
||||
|
||||
func parsePage(c *gin.Context) (int, int) {
|
||||
page, _ := strconv.Atoi(c.Query("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
func stringValue(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
func (h *AdminData) audit(c *gin.Context, action, resource, id, reason string, detail any) {
|
||||
_ = h.admin.WriteAudit(currentAdmin(c), action, resource, id, reason, c.ClientIP(), c.GetString("trace_id"), detail)
|
||||
}
|
||||
func (h *AdminData) bad(c *gin.Context, err error) {
|
||||
message := err.Error()
|
||||
code := "invalid_request"
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
code = "not_found"
|
||||
}
|
||||
fail(c, http.StatusBadRequest, code, message)
|
||||
}
|
||||
func (h *AdminData) internal(c *gin.Context, err error) {
|
||||
slog.ErrorContext(c.Request.Context(), "admin request failed", "trace_id", c.GetString("trace_id"), "error", err)
|
||||
fail(c, http.StatusInternalServerError, "internal_error", "操作失败,请稍后重试")
|
||||
}
|
||||
Reference in New Issue
Block a user