初始化

This commit is contained in:
Ran
2026-08-25 17:59:42 +08:00
commit 4b7380dd9b
408 changed files with 327400 additions and 0 deletions
+544
View File
@@ -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", "操作失败,请稍后重试")
}