Files
JuYou/API/internal/handler/admin_data.go
T
2026-08-25 17:59:42 +08:00

545 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 管理后台接口处理器,负责解析管理端请求并调用对应业务模块组织响应。
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", "操作失败,请稍后重试")
}