初始化

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
+111
View File
@@ -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")})
}
+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", "操作失败,请稍后重试")
}
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()
}
}
+210
View File
@@ -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
}
+48
View File
@@ -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,
})
}
+236
View File
@@ -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)
}
+29
View File
@@ -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)
}
})
}
}
+257
View File
@@ -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})
}
+166
View File
@@ -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)
}
+226
View File
@@ -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
}