初始化
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"juhe-factory/api/internal/model"
|
||||
"juhe-factory/api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const adminContextKey = "admin_user"
|
||||
|
||||
type AdminAuth struct{ service *service.Auth }
|
||||
|
||||
func NewAdminAuth(service *service.Auth) *AdminAuth { return &AdminAuth{service: service} }
|
||||
|
||||
func (h *AdminAuth) Login(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "请输入管理员账号和密码")
|
||||
return
|
||||
}
|
||||
pair, err := h.service.Login(body.Username, body.Password)
|
||||
if err != nil {
|
||||
fail(c, http.StatusUnauthorized, "invalid_credentials", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": pair})
|
||||
}
|
||||
|
||||
func (h *AdminAuth) Refresh(c *gin.Context) {
|
||||
var body struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "缺少刷新令牌")
|
||||
return
|
||||
}
|
||||
pair, err := h.service.Refresh(body.RefreshToken)
|
||||
if err != nil {
|
||||
fail(c, http.StatusUnauthorized, "invalid_refresh_token", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": pair})
|
||||
}
|
||||
|
||||
func (h *AdminAuth) Logout(c *gin.Context) {
|
||||
var body struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if err := h.service.Logout(body.RefreshToken); err != nil {
|
||||
fail(c, http.StatusInternalServerError, "logout_failed", "退出登录失败")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AdminAuth) ChangePassword(c *gin.Context) {
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
ConfirmPassword string `json:"confirm_password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "请完整填写密码")
|
||||
return
|
||||
}
|
||||
admin := currentAdmin(c)
|
||||
if err := h.service.ChangePassword(admin.ID, body.CurrentPassword, body.NewPassword, body.ConfirmPassword); err != nil {
|
||||
if errors.Is(err, service.ErrReauthFailed) || errors.Is(err, service.ErrAdminPasswordConfirmation) || errors.Is(err, service.ErrAdminPasswordLength) {
|
||||
_ = h.service.Audit(admin, "password_change_failed", "admin-auth", admin.ID.String(), err.Error(), c.ClientIP(), c.GetString("trace_id"), nil)
|
||||
fail(c, http.StatusBadRequest, "password_invalid", err.Error())
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, "password_update_failed", "密码修改失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
_ = h.service.Audit(admin, "password_changed", "admin-auth", admin.ID.String(), "", c.ClientIP(), c.GetString("trace_id"), nil)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AdminAuth) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
|
||||
admin, err := h.service.Authenticate(raw)
|
||||
if err != nil {
|
||||
fail(c, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(adminContextKey, admin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func currentAdmin(c *gin.Context) *model.AdminUser {
|
||||
value, _ := c.Get(adminContextKey)
|
||||
admin, _ := value.(*model.AdminUser)
|
||||
return admin
|
||||
}
|
||||
|
||||
func fail(c *gin.Context, status int, code, message string) {
|
||||
c.JSON(status, gin.H{"code": code, "message": message, "trace_id": c.GetString("trace_id")})
|
||||
}
|
||||
@@ -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", "操作失败,请稍后重试")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
// 本文件负责剧集视频 ZIP 下载请求的参数校验、响应头设置和对象流写入。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
|
||||
"juhe-factory/api/internal/service"
|
||||
"juhe-factory/api/internal/videoarchive"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DownloadEpisodeVideos 下载当前剧集按项目类型筛选后的视频 ZIP。
|
||||
func (h *Creative) DownloadEpisodeVideos(c *gin.Context) {
|
||||
if !h.objectStorageAvailable(c) {
|
||||
return
|
||||
}
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
manifest, err := h.service.EpisodeVideoArchive(currentWebUser(c).ID, projectID, episodeID)
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": manifest.Name}))
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Status(http.StatusOK)
|
||||
err = videoarchive.Write(c.Request.Context(), c.Writer, manifest.Entries, func(ctx context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
body, _, _, openErr := h.cos.Open(ctx, objectKey)
|
||||
return body, openErr
|
||||
})
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"juhe-factory/api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *Creative) PreviewDramaImport(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<20)
|
||||
if err := c.Request.ParseMultipartForm(4 << 20); err != nil {
|
||||
h.bad(c, errors.New("导入内容无效"))
|
||||
return
|
||||
}
|
||||
if c.Request.MultipartForm != nil {
|
||||
defer c.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
rawText := strings.TrimSpace(c.PostForm("raw_text"))
|
||||
var filename string
|
||||
var content []byte
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
filename = header.Filename
|
||||
content, err = io.ReadAll(io.LimitReader(file, 3*1024*1024+1))
|
||||
if err != nil {
|
||||
h.bad(c, errors.New("读取小说文件失败"))
|
||||
return
|
||||
}
|
||||
} else if rawText == "" {
|
||||
h.bad(c, errors.New("请选择小说文件或粘贴文本"))
|
||||
return
|
||||
}
|
||||
preview, err := h.service.PreviewDramaImport(currentWebUser(c).ID, projectID, filename, content, rawText)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": preview})
|
||||
}
|
||||
|
||||
func (h *Creative) ConfirmDramaImport(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ImportToken uuid.UUID `json:"import_token"`
|
||||
ChaptersPerEpisode int `json:"chapters_per_episode"`
|
||||
StartEpisodeNo int `json:"start_episode_no"`
|
||||
TreatAsSingleEpisode bool `json:"treat_as_single_episode"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ImportToken == uuid.Nil {
|
||||
h.bad(c, errors.New("导入确认参数无效"))
|
||||
return
|
||||
}
|
||||
items, err := h.service.ConfirmDramaImport(currentWebUser(c).ID, projectID, body.ImportToken, body.ChaptersPerEpisode, body.StartEpisodeNo, body.TreatAsSingleEpisode)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *Creative) GetDramaEpisodeSource(c *gin.Context) {
|
||||
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
source, err := h.service.EpisodeSource(currentWebUser(c).ID, projectID, episodeID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": source})
|
||||
}
|
||||
|
||||
func (h *Creative) SaveDramaEpisodeSource(c *gin.Context) {
|
||||
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
RawContent string `json:"raw_content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
source, err := h.service.SaveEpisodeSource(currentWebUser(c).ID, projectID, episodeID, body.RawContent, body.Title)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": source})
|
||||
}
|
||||
|
||||
func (h *Creative) QueueDramaParse(c *gin.Context) {
|
||||
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
task, err := h.service.QueueDramaParse(currentWebUser(c).ID, projectID, episodeID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||
}
|
||||
|
||||
func (h *Creative) ReparseDrama(c *gin.Context) {
|
||||
if !h.objectStorageAvailable(c) {
|
||||
return
|
||||
}
|
||||
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := currentWebUser(c).ID
|
||||
objectKeys, err := h.service.ResetEpisodeAnalysis(userID, projectID, episodeID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
if !h.deleteStoredObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
task, err := h.service.QueueDramaParse(userID, projectID, episodeID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||
}
|
||||
|
||||
func (h *Creative) ListDramaParseTasks(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var episodeID *uuid.UUID
|
||||
if raw := strings.TrimSpace(c.Query("episode_id")); raw != "" {
|
||||
parsed, err := service.ParseUUID(raw, "剧集")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
episodeID = &parsed
|
||||
}
|
||||
items, err := h.service.ListDramaParseTasks(currentWebUser(c).ID, projectID, episodeID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *Creative) CancelDramaParse(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
taskID, err := service.ParseUUID(c.Param("task_id"), "解析任务")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if err = h.service.CancelDramaParse(currentWebUser(c).ID, projectID, taskID); err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Creative) CreateDramaStoryboard(c *gin.Context) {
|
||||
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
storyboard, err := h.service.CreateStoryboard(currentWebUser(c).ID, projectID, episodeID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": storyboard})
|
||||
}
|
||||
|
||||
func (h *Creative) dramaEpisodeIDs(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return projectID, episodeID, true
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Health struct {
|
||||
db *sql.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewHealth(db *sql.DB, redisClient *redis.Client) *Health {
|
||||
return &Health{db: db, redis: redisClient}
|
||||
}
|
||||
|
||||
func (h *Health) Check(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
services := map[string]string{
|
||||
"postgresql": "ok",
|
||||
"redis": "ok",
|
||||
}
|
||||
status := "ok"
|
||||
statusCode := http.StatusOK
|
||||
|
||||
if err := h.db.PingContext(ctx); err != nil {
|
||||
services["postgresql"] = "unavailable"
|
||||
status = "degraded"
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
if err := h.redis.Ping(ctx).Err(); err != nil {
|
||||
services["redis"] = "unavailable"
|
||||
status = "degraded"
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
c.JSON(statusCode, gin.H{
|
||||
"status": status,
|
||||
"services": services,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// 图片生成接口处理器,负责生图参数校验、参考图上传、历史查询和结果删除。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
mediakey "juhe-factory/api/internal/media"
|
||||
"juhe-factory/api/internal/model"
|
||||
"juhe-factory/api/internal/modules/productimage"
|
||||
"juhe-factory/api/internal/service"
|
||||
"juhe-factory/api/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ProductImage 组合图片生成生图服务与对象存储依赖。
|
||||
type ProductImage struct {
|
||||
service *productimage.Service
|
||||
cos *storage.COS
|
||||
}
|
||||
|
||||
// NewProductImage 创建图片生成 HTTP 处理器。
|
||||
func NewProductImage(service *productimage.Service, cos *storage.COS) *ProductImage {
|
||||
return &ProductImage{service: service, cos: cos}
|
||||
}
|
||||
|
||||
// Generate 接收当前页面的临时配置和参考图,并创建图片生成任务。
|
||||
func (h *ProductImage) Generate(c *gin.Context) {
|
||||
if h.cos == nil {
|
||||
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "对象存储未配置")
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 48<<20)
|
||||
if err := c.Request.ParseMultipartForm(48 << 20); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "生图请求无效")
|
||||
return
|
||||
}
|
||||
if c.Request.MultipartForm != nil {
|
||||
defer c.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
modelID, err := service.ParseUUID(c.PostForm("model_id"), "图片模型")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
ratio := strings.TrimSpace(c.PostForm("aspect_ratio"))
|
||||
if ratio == "" {
|
||||
ratio = "9:16"
|
||||
}
|
||||
resolution := strings.TrimSpace(c.PostForm("resolution"))
|
||||
if resolution == "" {
|
||||
resolution = "1k"
|
||||
}
|
||||
userID := currentWebUser(c).ID
|
||||
files := c.Request.MultipartForm.File["references"]
|
||||
if len(files) > 4 {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "最多上传 4 张参考图")
|
||||
return
|
||||
}
|
||||
references := make([]productimage.ReferenceUpload, 0, len(files))
|
||||
objectKeys := make([]string, 0, len(files))
|
||||
for index, header := range files {
|
||||
file, openErr := header.Open()
|
||||
if openErr != nil {
|
||||
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "参考图无法读取")
|
||||
return
|
||||
}
|
||||
contentType := cleanProductImageContentType(header.Header.Get("Content-Type"))
|
||||
if contentType == "" {
|
||||
contentType = cleanProductImageContentType(mime.TypeByExtension(strings.ToLower(filepath.Ext(header.Filename))))
|
||||
}
|
||||
allowed := map[string]bool{"image/jpeg": true, "image/png": true, "image/webp": true}
|
||||
if !allowed[contentType] || header.Size <= 0 || header.Size > h.cos.MaxImageBytes() {
|
||||
file.Close()
|
||||
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "参考图格式或大小不符合要求")
|
||||
return
|
||||
}
|
||||
config, _, decodeErr := image.DecodeConfig(file)
|
||||
if decodeErr != nil || config.Width <= 0 || config.Height <= 0 {
|
||||
file.Close()
|
||||
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "参考图内容无法解析")
|
||||
return
|
||||
}
|
||||
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
||||
file.Close()
|
||||
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
mediaID := uuid.New()
|
||||
key := mediakey.ProductImageReference(userID, mediaID, header.Filename, contentType)
|
||||
hash := sha256.New()
|
||||
if _, err = io.Copy(hash, file); err != nil {
|
||||
file.Close()
|
||||
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
||||
file.Close()
|
||||
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
url, putErr := h.cos.Put(c, key, contentType, file, header.Size)
|
||||
file.Close()
|
||||
if putErr != nil {
|
||||
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
h.respondError(c, putErr)
|
||||
return
|
||||
}
|
||||
objectKeys = append(objectKeys, key)
|
||||
name := strings.TrimSpace(filepath.Base(header.Filename))
|
||||
if name == "" {
|
||||
name = "参考图" + string(rune('1'+index))
|
||||
}
|
||||
owner := userID
|
||||
references = append(references, productimage.ReferenceUpload{Asset: &model.MediaAsset{ID: mediaID, OwnerUserID: &owner, StorageProvider: "cos", ObjectKey: key, PublicURL: url, OriginalName: name, DisplayName: "图片生成参考图", MimeType: contentType, SizeBytes: header.Size, SHA256: hex.EncodeToString(hash.Sum(nil)), Width: &config.Width, Height: &config.Height}, Name: name})
|
||||
}
|
||||
task, err := h.service.QueueGeneration(userID, productimage.GenerateInput{Prompt: c.PostForm("prompt"), ModelID: modelID, AspectRatio: ratio, Resolution: resolution, References: references})
|
||||
if err != nil {
|
||||
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"data": gin.H{"task_id": task.ID}})
|
||||
}
|
||||
|
||||
// ListGenerations 返回当前用户最近的图片生成历史。
|
||||
func (h *ProductImage) ListGenerations(c *gin.Context) {
|
||||
items, err := h.service.Generations(currentWebUser(c).ID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// DeleteGeneration 删除当前用户的一条图片生成记录及其 COS 对象。
|
||||
func (h *ProductImage) DeleteGeneration(c *gin.Context) {
|
||||
taskID, err := service.ParseUUID(c.Param("task_id"), "图片生成任务")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
err = h.service.DeleteGeneration(currentWebUser(c).ID, taskID, func(keys []string) error {
|
||||
return h.deleteObjects(c.Request.Context(), keys)
|
||||
})
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteObjects 删除一组已经精确解析的 COS 对象键。
|
||||
func (h *ProductImage) deleteObjects(ctx context.Context, keys []string) error {
|
||||
if h.cos == nil {
|
||||
return nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
if err := h.cos.Delete(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupUploadedObjects 清理提交失败前已经上传的参考图,并统一响应清理错误。
|
||||
func (h *ProductImage) cleanupUploadedObjects(c *gin.Context, keys []string) bool {
|
||||
if err := h.deleteObjects(c.Request.Context(), keys); err != nil {
|
||||
h.respondError(c, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanProductImageContentType 去除图片生成上传类型的参数部分。
|
||||
func cleanProductImageContentType(value string) string {
|
||||
return strings.TrimSpace(strings.Split(value, ";")[0])
|
||||
}
|
||||
|
||||
// respondError 将图片生成模块错误转换为一致的 Web API 响应。
|
||||
func (h *ProductImage) respondError(c *gin.Context, err error) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
fail(c, http.StatusNotFound, "not_found", "图片生成记录不存在")
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(err.Error())
|
||||
if message == "" {
|
||||
message = "请求处理失败"
|
||||
}
|
||||
if strings.Contains(message, "不能为空") || strings.Contains(message, "不能") || strings.Contains(message, "无效") || strings.Contains(message, "请选择") || strings.Contains(message, "最多") || strings.Contains(message, "不可用") || strings.Contains(message, "未配置") {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", message)
|
||||
return
|
||||
}
|
||||
if strings.Contains(message, "积分") {
|
||||
fail(c, http.StatusConflict, "insufficient_points", message)
|
||||
return
|
||||
}
|
||||
if strings.Contains(message, "生成中") {
|
||||
fail(c, http.StatusConflict, "generation_active", message)
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, "internal_error", message)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateRedemptionBatchLimits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
points any
|
||||
quantity int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "minimum", points: 1, quantity: 1},
|
||||
{name: "maximum", points: 100, quantity: 10},
|
||||
{name: "decimal points", points: 1.5, quantity: 2},
|
||||
{name: "points below minimum", points: 0.99, quantity: 1, wantErr: true},
|
||||
{name: "points above maximum", points: 100.01, quantity: 1, wantErr: true},
|
||||
{name: "quantity below minimum", points: 1, quantity: 0, wantErr: true},
|
||||
{name: "quantity above maximum", points: 1, quantity: 11, wantErr: true},
|
||||
{name: "invalid points", points: "invalid", quantity: 1, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := validateRedemptionBatchLimits(test.points, test.quantity)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("validateRedemptionBatchLimits(%v, %d) error = %v", test.points, test.quantity, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// 视频重绘接口处理器,负责重绘工作台、分析任务和源媒体上传请求。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
mediakey "juhe-factory/api/internal/media"
|
||||
"juhe-factory/api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *Creative) GetRedrawWorkbench(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.GetRedrawWorkbench(currentWebUser(c).ID, projectID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *Creative) GetRedrawEpisodeWorkbench(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
data, err := h.service.GetRedrawEpisodeWorkbench(currentWebUser(c).ID, projectID, episodeID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *Creative) QueueRedrawEpisodeScript(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
task, err := h.service.QueueRedrawEpisodeScript(currentWebUser(c).ID, projectID, episodeID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||
}
|
||||
|
||||
// SaveRedrawEpisodeScript 保存用户修改后的剧集反推剧本正文。
|
||||
func (h *Creative) SaveRedrawEpisodeScript(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.service.SaveRedrawEpisodeScript(currentWebUser(c).ID, projectID, episodeID, body.Content); err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Creative) UpdateRedrawAnalysisSettings(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
AudioSource string `json:"audio_source"`
|
||||
SourceLanguage string `json:"source_language"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.service.UpdateRedrawAnalysisSettings(currentWebUser(c).ID, projectID, body.AudioSource, body.SourceLanguage); err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Creative) QueueRedrawAnalysis(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
task, err := h.service.QueueRedrawAnalysis(currentWebUser(c).ID, projectID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||
}
|
||||
|
||||
func (h *Creative) ReanalyzeRedraw(c *gin.Context) {
|
||||
if !h.objectStorageAvailable(c) {
|
||||
return
|
||||
}
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
objectKeys, err := h.service.ResetRedrawAnalysis(currentWebUser(c).ID, projectID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
if !h.deleteStoredObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
task, err := h.service.QueueRedrawAnalysis(currentWebUser(c).ID, projectID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||
}
|
||||
|
||||
func (h *Creative) QueueRedrawStoryboardAnalysis(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
task, err := h.service.QueueRedrawStoryboardAnalysis(currentWebUser(c).ID, projectID, storyboardID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||
}
|
||||
|
||||
func (h *Creative) QueueRedrawScript(c *gin.Context) {
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
task, err := h.service.QueueRedrawScript(currentWebUser(c).ID, projectID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||
}
|
||||
|
||||
func (h *Creative) UploadRedrawSource(c *gin.Context) {
|
||||
h.uploadRedrawMedia(c, "source")
|
||||
}
|
||||
|
||||
func (h *Creative) UploadRedrawSubtitle(c *gin.Context) {
|
||||
h.uploadRedrawMedia(c, "subtitle")
|
||||
}
|
||||
|
||||
func (h *Creative) uploadRedrawMedia(c *gin.Context, kind string) {
|
||||
if h.cos == nil {
|
||||
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置")
|
||||
return
|
||||
}
|
||||
projectID, ok := h.projectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
target, err := h.service.PrepareRedrawMediaUpload(currentWebUser(c).ID, projectID, kind)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
project := target.Project
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
h.bad(c, errors.New("请选择文件"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
contentType := cleanContentType(header.Header.Get("Content-Type"))
|
||||
maxBytes := int64(50 * 1024 * 1024)
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
allowedVideoTypes := map[string]bool{"video/mp4": true, "video/webm": true, "video/quicktime": true, "video/x-m4v": true}
|
||||
allowed := allowedVideoTypes[contentType] && map[string]bool{".mp4": true, ".webm": true, ".mov": true, ".m4v": true}[ext]
|
||||
if kind == "subtitle" {
|
||||
maxBytes = 2 * 1024 * 1024
|
||||
allowed = ext == ".srt" || ext == ".vtt" || ext == ".txt"
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
}
|
||||
}
|
||||
if !allowed || header.Size <= 0 || header.Size > maxBytes {
|
||||
h.bad(c, fmt.Errorf("%s文件格式或大小不符合要求", map[bool]string{true: "字幕", false: "视频"}[kind == "subtitle"]))
|
||||
return
|
||||
}
|
||||
oldMediaID := target.OldMediaID
|
||||
if kind == "source" && project.SourceVideoAssetID != nil {
|
||||
objectKeys, err := h.service.ResetRedrawAnalysis(currentWebUser(c).ID, projectID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
if !h.deleteStoredObjects(c, objectKeys) {
|
||||
return
|
||||
}
|
||||
}
|
||||
mediaID := uuid.New()
|
||||
key := mediakey.ProjectSource(projectID, mediaID, header.Filename, contentType)
|
||||
displayName := project.Name + "原视频"
|
||||
if kind == "subtitle" {
|
||||
key = mediakey.ProjectSubtitle(projectID, mediaID, header.Filename, contentType)
|
||||
displayName = project.Name + "字幕"
|
||||
}
|
||||
asset, err := h.storeUpload(c, mediaID, currentWebUser(c).ID, file, header, key, displayName, contentType, nil)
|
||||
if err != nil {
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.service.PersistRedrawMediaUpload(projectID, kind, project.RedrawStatus, asset, oldMediaID); err != nil {
|
||||
_ = h.cos.Delete(c.Request.Context(), asset.ObjectKey)
|
||||
h.internal(c, err)
|
||||
return
|
||||
}
|
||||
if target.OldObjectKey != "" && !h.deleteStoredObjects(c, []string{target.OldObjectKey}) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": asset})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"juhe-factory/api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *Creative) ListScriptAnalyses(c *gin.Context) {
|
||||
items, err := h.service.ListScriptAnalyses(currentWebUser(c).ID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *Creative) CreateScriptAnalysis(c *gin.Context) {
|
||||
item, err := h.service.CreateScriptAnalysis(currentWebUser(c).ID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *Creative) ListScriptAnalysisImportProjects(c *gin.Context) {
|
||||
items, err := h.service.ListScriptAnalysisImportProjects(currentWebUser(c).ID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *Creative) ImportScriptAnalysisProject(c *gin.Context) {
|
||||
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ProjectID == uuid.Nil {
|
||||
h.bad(c, errors.New("请选择要导入的剧本反推项目"))
|
||||
return
|
||||
}
|
||||
item, err := h.service.ImportScriptAnalysisProject(currentWebUser(c).ID, id, body.ProjectID)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *Creative) ImportScriptAnalysisFile(c *gin.Context) {
|
||||
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<20)
|
||||
if err := c.Request.ParseMultipartForm(4 << 20); err != nil {
|
||||
h.bad(c, errors.New("导入文件无效"))
|
||||
return
|
||||
}
|
||||
if c.Request.MultipartForm != nil {
|
||||
defer c.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
h.bad(c, errors.New("请选择剧本文件"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(file, 3*1024*1024+1))
|
||||
if err != nil {
|
||||
h.bad(c, errors.New("读取剧本文件失败"))
|
||||
return
|
||||
}
|
||||
item, err := h.service.ImportScriptAnalysisFile(currentWebUser(c).ID, id, header.Filename, content)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *Creative) GetScriptAnalysis(c *gin.Context) {
|
||||
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
item, err := h.service.GetScriptAnalysis(currentWebUser(c).ID, id)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *Creative) UpdateScriptAnalysis(c *gin.Context) {
|
||||
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
var input service.ScriptAnalysisInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateScriptAnalysis(currentWebUser(c).ID, id, input)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *Creative) DeleteScriptAnalysis(c *gin.Context) {
|
||||
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteScriptAnalysis(currentWebUser(c).ID, id); err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Creative) QueueScriptAnalysis(c *gin.Context) {
|
||||
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
task, err := h.service.QueueScriptAnalysis(currentWebUser(c).ID, id)
|
||||
if err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||
}
|
||||
|
||||
func (h *Creative) CancelScriptAnalysis(c *gin.Context) {
|
||||
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||
if err != nil {
|
||||
h.bad(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.service.CancelScriptAnalysis(currentWebUser(c).ID, id); err != nil {
|
||||
h.respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// WEB 用户接口处理器,负责认证、账户资料、积分查询、头像上传和兑换请求。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
mediakey "juhe-factory/api/internal/media"
|
||||
"juhe-factory/api/internal/model"
|
||||
"juhe-factory/api/internal/service"
|
||||
"juhe-factory/api/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const webUserContextKey = "web_user"
|
||||
|
||||
type Web struct {
|
||||
service *service.Web
|
||||
cos *storage.COS
|
||||
}
|
||||
|
||||
func NewWeb(service *service.Web, cos *storage.COS) *Web { return &Web{service: service, cos: cos} }
|
||||
|
||||
func (h *Web) Login(c *gin.Context) {
|
||||
var body struct {
|
||||
Account string `json:"account" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "请输入账号和密码")
|
||||
return
|
||||
}
|
||||
pair, err := h.service.Login(body.Account, body.Password)
|
||||
if err != nil {
|
||||
fail(c, http.StatusUnauthorized, "invalid_credentials", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": pair})
|
||||
}
|
||||
func (h *Web) Refresh(c *gin.Context) {
|
||||
var body struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "缺少刷新令牌")
|
||||
return
|
||||
}
|
||||
pair, err := h.service.Refresh(body.RefreshToken)
|
||||
if err != nil {
|
||||
fail(c, http.StatusUnauthorized, "invalid_refresh_token", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": pair})
|
||||
}
|
||||
func (h *Web) Logout(c *gin.Context) {
|
||||
var body struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if err := h.service.Logout(body.RefreshToken); err != nil {
|
||||
fail(c, http.StatusInternalServerError, "logout_failed", "退出失败,请重试")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
func (h *Web) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
|
||||
user, err := h.service.Authenticate(raw)
|
||||
if err != nil {
|
||||
fail(c, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(webUserContextKey, user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Account 返回当前用户账户资料、积分额度和头像上传限制。
|
||||
func (h *Web) Account(c *gin.Context) {
|
||||
data, err := h.service.Account(currentWebUser(c).ID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "account_failed", "账户信息加载失败")
|
||||
return
|
||||
}
|
||||
if h.cos != nil {
|
||||
data["avatar_max_size_bytes"] = h.cos.MaxImageBytes()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
func (h *Web) UpdateProfile(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "请输入用户名")
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateProfile(currentWebUser(c).ID, body.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrInvalidUsername) || errors.Is(err, service.ErrUsernameExists) {
|
||||
fail(c, http.StatusBadRequest, "profile_invalid", err.Error())
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, "profile_update_failed", "资料更新失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
func (h *Web) ChangePassword(c *gin.Context) {
|
||||
var body struct {
|
||||
OriginalPassword string `json:"original_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "请完整填写密码")
|
||||
return
|
||||
}
|
||||
if err := h.service.ChangePassword(currentWebUser(c).ID, body.OriginalPassword, body.NewPassword); err != nil {
|
||||
if errors.Is(err, service.ErrOriginalPassword) || strings.Contains(err.Error(), "密码长度") {
|
||||
fail(c, http.StatusBadRequest, "password_invalid", err.Error())
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, "password_update_failed", "密码修改失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
func (h *Web) UploadAvatar(c *gin.Context) {
|
||||
if h.cos == nil {
|
||||
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "对象存储未配置")
|
||||
return
|
||||
}
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "请选择头像图片")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
contentType := strings.TrimSpace(strings.Split(header.Header.Get("Content-Type"), ";")[0])
|
||||
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
extension, allowed := extensions[contentType]
|
||||
if !allowed || header.Size <= 0 || header.Size > h.cos.MaxImageBytes() {
|
||||
fail(c, http.StatusBadRequest, "avatar_invalid", "头像仅支持 JPEG、PNG 或 WebP,且不得超过图片大小限制")
|
||||
return
|
||||
}
|
||||
headerBytes := make([]byte, 512)
|
||||
read, readErr := file.Read(headerBytes)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
fail(c, http.StatusBadRequest, "avatar_invalid", "头像图片内容无法解析")
|
||||
return
|
||||
}
|
||||
if detected := http.DetectContentType(headerBytes[:read]); detected != contentType {
|
||||
fail(c, http.StatusBadRequest, "avatar_invalid", "头像图片内容无法解析")
|
||||
return
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
fail(c, http.StatusInternalServerError, "avatar_upload_failed", "头像上传失败")
|
||||
return
|
||||
}
|
||||
userID := currentWebUser(c).ID
|
||||
oldKey, err := h.service.AvatarKey(userID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "avatar_upload_failed", "头像上传失败")
|
||||
return
|
||||
}
|
||||
key := mediakey.UserAvatar(userID, uuid.New(), extension)
|
||||
url, err := h.cos.Put(c.Request.Context(), key, contentType, file, header.Size)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "avatar_upload_failed", "头像上传失败")
|
||||
return
|
||||
}
|
||||
if err := h.service.UpdateAvatar(userID, url, key); err != nil {
|
||||
_ = h.cos.Delete(c.Request.Context(), key)
|
||||
fail(c, http.StatusInternalServerError, "avatar_upload_failed", "头像上传失败")
|
||||
return
|
||||
}
|
||||
if oldKey != "" {
|
||||
_ = h.cos.Delete(c.Request.Context(), oldKey)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"avatar_url": url}})
|
||||
}
|
||||
func (h *Web) Usage30Days(c *gin.Context) {
|
||||
items, err := h.service.Usage30Days(currentWebUser(c).ID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "usage_failed", "消耗数据加载失败")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// ConsumptionRecords 按请求页码返回当前用户的积分变动流水。
|
||||
func (h *Web) ConsumptionRecords(c *gin.Context) {
|
||||
page, pageSize := parsePage(c)
|
||||
result, err := h.service.ConsumptionRecords(currentWebUser(c).ID, page, pageSize)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "consumption_records_failed", "消耗记录加载失败")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
func (h *Web) Redeem(c *gin.Context) {
|
||||
var body struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid_request", "请输入兑换码")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Redeem(currentWebUser(c).ID, body.Code)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "redeem_failed", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
func currentWebUser(c *gin.Context) *model.WebUser {
|
||||
value, _ := c.Get(webUserContextKey)
|
||||
user, _ := value.(*model.WebUser)
|
||||
return user
|
||||
}
|
||||
Reference in New Issue
Block a user