Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminReadHandler struct {
|
||||
admin *service.AdminReadService
|
||||
}
|
||||
|
||||
func NewAdminReadHandler(admin *service.AdminReadService) *AdminReadHandler {
|
||||
return &AdminReadHandler{admin: admin}
|
||||
}
|
||||
|
||||
func (h *AdminReadHandler) Users(c *gin.Context) {
|
||||
users, stats, err := h.admin.Users(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load users"})
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(users))
|
||||
generationCounts := map[string]int64{}
|
||||
if raw, ok := stats["generation_counts"].(map[string]int64); ok {
|
||||
generationCounts = raw
|
||||
}
|
||||
for _, user := range users {
|
||||
row := userPublic(user)
|
||||
row["generation_count"] = generationCounts[user.ID]
|
||||
out = append(out, row)
|
||||
}
|
||||
delete(stats, "generation_counts")
|
||||
c.JSON(http.StatusOK, gin.H{"data": out, "stats": stats})
|
||||
}
|
||||
|
||||
func (h *AdminReadHandler) Models(c *gin.Context) {
|
||||
items, err := h.admin.ModelsView(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminReadHandler) Logs(c *gin.Context) {
|
||||
limit := parseInt(c.Query("limit"), 50)
|
||||
offset := parseInt(c.Query("offset"), 0)
|
||||
kind := c.Query("kind")
|
||||
status := c.Query("status")
|
||||
var since *time.Time
|
||||
if raw := c.Query("since"); raw != "" {
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
t := time.Unix(int64(f), 0)
|
||||
since = &t
|
||||
}
|
||||
}
|
||||
|
||||
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, since, "", "", c.Query("source"), false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
}
|
||||
// Resolve user_id -> display name once for the page (mirrors admin.py).
|
||||
nameByID, err := h.admin.UserNameMap(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
var userName any
|
||||
if item.UserID == "" {
|
||||
userName = "匿名"
|
||||
} else if name, ok := nameByID[item.UserID]; ok {
|
||||
userName = name
|
||||
} else {
|
||||
userName = item.UserID
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": item.ID,
|
||||
"ts": item.TS.Unix(),
|
||||
"kind": item.Kind,
|
||||
"status": item.Status,
|
||||
"model": item.Model,
|
||||
"provider": item.Provider,
|
||||
"prompt": item.Prompt,
|
||||
"ratio": item.Ratio,
|
||||
"resolution": item.Resolution,
|
||||
"duration": item.Duration,
|
||||
"refs": item.Refs,
|
||||
"source": item.Source,
|
||||
"user_id": emptyStringNil(item.UserID),
|
||||
"user_name": userName,
|
||||
"cost": item.Cost,
|
||||
"elapsed_ms": item.ElapsedMS,
|
||||
"file": emptyStringNil(item.File),
|
||||
"error": emptyStringNil(item.Error),
|
||||
"created_at": unixSec(item.CreatedAt),
|
||||
"updated_at": unixSec(item.UpdatedAt),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": out,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminReadHandler) Stats(c *gin.Context) {
|
||||
stats, err := h.admin.Stats(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load stats"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (h *AdminReadHandler) Dashboard(c *gin.Context) {
|
||||
data, err := h.admin.Dashboard(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load dashboard"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *AdminReadHandler) Invites(c *gin.Context) {
|
||||
items, stats, err := h.admin.Invites(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load invites"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items, "stats": stats})
|
||||
}
|
||||
|
||||
func (h *AdminReadHandler) Providers(c *gin.Context) {
|
||||
items, err := h.admin.Providers(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load providers"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminReadHandler) Images(c *gin.Context) {
|
||||
limit := parseInt(c.Query("limit"), 30)
|
||||
offset := parseInt(c.Query("offset"), 0)
|
||||
kind := c.Query("kind")
|
||||
items, total, stats, err := h.admin.Images(c.Request.Context(), limit, offset, kind)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load images"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
func userPublic(user model.User) gin.H {
|
||||
keys := make([]gin.H, 0, len(user.APIKeys))
|
||||
for _, key := range user.APIKeys {
|
||||
keys = append(keys, gin.H{
|
||||
"id": key.ID,
|
||||
"name": key.Name,
|
||||
"key_preview": key.KeyPreview,
|
||||
"created_at": unixSec(key.CreatedAt),
|
||||
"last_used_at": unixSecPtr(key.LastUsedAt),
|
||||
})
|
||||
}
|
||||
return gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"credits": user.Credits,
|
||||
"notes": user.Notes,
|
||||
"created_at": unixSec(user.CreatedAt),
|
||||
"last_login_at": unixSecPtr(user.LastLoginAt),
|
||||
"last_login_ip": user.LastLoginIP,
|
||||
"invite_code": user.InviteCode,
|
||||
"invited_by": user.InvitedBy,
|
||||
"checkin_last": user.CheckinLast,
|
||||
"checkin_streak": user.CheckinStreak,
|
||||
"api_keys": keys,
|
||||
"has_password": user.PasswordHash != "",
|
||||
}
|
||||
}
|
||||
|
||||
// unixSec / unixSecPtr render timestamps as unix SECONDS — the frontend's
|
||||
// fmtTs/fmtRelative expect seconds (matching the Python reference's time.time()),
|
||||
// not the RFC3339 string Go marshals a time.Time into (which parses to NaN → "—").
|
||||
func unixSec(t time.Time) any {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func unixSecPtr(t *time.Time) any {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func parseInt(raw string, fallback int) int {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
return n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminWriteHandler struct {
|
||||
admin *service.AdminWriteService
|
||||
}
|
||||
|
||||
func NewAdminWriteHandler(admin *service.AdminWriteService) *AdminWriteHandler {
|
||||
return &AdminWriteHandler{admin: admin}
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) CreateUser(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
user, err := h.admin.CreateUser(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": userPublic(*user)})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) UpdateUser(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
user, err := h.admin.UpdateUser(c.Request.Context(), c.Param("user_id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": userPublic(*user)})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) DeleteUser(c *gin.Context) {
|
||||
if err := h.admin.DeleteUser(c.Request.Context(), c.Param("user_id")); err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// DeleteUsersBulk removes multiple users in one call (multi-select).
|
||||
func (h *AdminWriteHandler) DeleteUsersBulk(c *gin.Context) {
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if len(body.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何用户"})
|
||||
return
|
||||
}
|
||||
n, err := h.admin.DeleteUsers(c.Request.Context(), body.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete users"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) AdjustUserCredits(c *gin.Context) {
|
||||
var body struct {
|
||||
Delta float64 `json:"delta"`
|
||||
Set *float64 `json:"set"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
var (
|
||||
user *model.User
|
||||
err error
|
||||
)
|
||||
if body.Set != nil {
|
||||
// Absolute set takes precedence over delta (matches Python admin.py).
|
||||
user, err = h.admin.SetUserCredits(c.Request.Context(), c.Param("user_id"), *body.Set)
|
||||
} else {
|
||||
user, err = h.admin.AdjustUserCredits(c.Request.Context(), c.Param("user_id"), body.Delta)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": userPublic(*user)})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) CreateUserAPIKey(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
key, plain, err := h.admin.CreateUserAPIKey(c.Request.Context(), c.Param("user_id"), body.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"key": plain,
|
||||
"data": gin.H{
|
||||
"id": key.ID,
|
||||
"name": key.Name,
|
||||
"key_preview": key.KeyPreview,
|
||||
"created_at": key.CreatedAt,
|
||||
"last_used_at": key.LastUsedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) DeleteUserAPIKey(c *gin.Context) {
|
||||
if err := h.admin.DeleteUserAPIKey(c.Request.Context(), c.Param("user_id"), c.Param("key_id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) CreateShowcase(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.admin.CreateShowcase(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) UpdateShowcase(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.admin.UpdateShowcase(c.Request.Context(), c.Param("entry_id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) DeleteShowcase(c *gin.Context) {
|
||||
if err := h.admin.DeleteShowcase(c.Request.Context(), c.Param("entry_id")); err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "showcase not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete showcase"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) CreateModel(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.admin.CreateModel(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) UpdateModel(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.admin.UpdateModel(c.Request.Context(), c.Param("model_id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) DeleteModel(c *gin.Context) {
|
||||
if err := h.admin.DeleteModel(c.Request.Context(), c.Param("model_id")); err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "model not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete model"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) ClearLogs(c *gin.Context) {
|
||||
removed, err := h.admin.ClearLogs(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to clear logs"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "removed": removed})
|
||||
}
|
||||
|
||||
func (h *AdminWriteHandler) ClearPendingLogs(c *gin.Context) {
|
||||
removed, err := h.admin.ClearPendingLogs(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to clear pending logs"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "removed": removed})
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AppSettingsHandler struct {
|
||||
settings *service.AppSettingsService
|
||||
}
|
||||
|
||||
func NewAppSettingsHandler(settings *service.AppSettingsService) *AppSettingsHandler {
|
||||
return &AppSettingsHandler{settings: settings}
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) RegistrationGet(c *gin.Context) {
|
||||
data, err := h.settings.Registration(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load registration settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) RegistrationPut(c *gin.Context) {
|
||||
var body service.RegistrationSettings
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
data, err := h.settings.SaveRegistration(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": data})
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) SMTPGet(c *gin.Context) {
|
||||
data, err := h.settings.SMTP(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load smtp settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) SMTPPut(c *gin.Context) {
|
||||
var body service.SMTPSettings
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
data, err := h.settings.SaveSMTP(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": data})
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) SMTPTest(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if err := h.settings.TestSMTP(c.Request.Context(), body.Email); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "detail": "测试邮件已发送"})
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) ProxyGet(c *gin.Context) {
|
||||
data, err := h.settings.Proxy(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load proxy settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) ProxyPut(c *gin.Context) {
|
||||
var body struct {
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
data, err := h.settings.SaveProxy(c.Request.Context(), body.Proxy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": data})
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) ProxyTest(c *gin.Context) {
|
||||
var body struct {
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
data, err := h.settings.TestProxy(c.Request.Context(), body.Proxy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": data})
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) CreditsGet(c *gin.Context) {
|
||||
data, err := h.settings.Credits(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load credit settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) CreditsPut(c *gin.Context) {
|
||||
var body service.CreditSettings
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
data, err := h.settings.SaveCredits(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": data})
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) LogsGet(c *gin.Context) {
|
||||
data, err := h.settings.Logs(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load log settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) LogsPut(c *gin.Context) {
|
||||
var body struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
data, err := h.settings.SaveLogs(c.Request.Context(), body.RetentionDays)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": data})
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) MediaGet(c *gin.Context) {
|
||||
data, err := h.settings.Media(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load media settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) MediaPut(c *gin.Context) {
|
||||
var body struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
data, err := h.settings.SaveMedia(c.Request.Context(), body.RetentionDays)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": data.Settings, "removed": data.Removed, "freed_bytes": data.FreedBytes})
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/config"
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
auth *service.AuthService
|
||||
limiter *service.RateLimitService
|
||||
}
|
||||
|
||||
func NewAuthHandler(cfg *config.Config, auth *service.AuthService, limiter *service.RateLimitService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
cfg: cfg,
|
||||
auth: auth,
|
||||
limiter: limiter,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Config(c *gin.Context) {
|
||||
data, err := h.auth.AuthConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load auth config"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) SendCode(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
ip := clientIP(c)
|
||||
if err := h.enforceRateLimit(c, "auth:send-code:ip:"+ip, 5, time.Hour); err != nil {
|
||||
return
|
||||
}
|
||||
if email, err := service.ValidateEmail(body.Email); err == nil {
|
||||
if err := h.enforceRateLimit(c, "auth:send-code:email:"+email, 3, 10*time.Minute); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.auth.SendCode(c.Request.Context(), body.Email, body.Purpose); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
EmailCode string `json:"email_code"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(body.Username)
|
||||
if username == "" {
|
||||
username = strings.TrimSpace(body.Name)
|
||||
}
|
||||
if err := h.enforceRateLimit(c, "auth:register:ip:"+clientIP(c), 10, time.Hour); err != nil {
|
||||
return
|
||||
}
|
||||
emailCode := strings.TrimSpace(body.EmailCode)
|
||||
if emailCode == "" {
|
||||
emailCode = strings.TrimSpace(body.Code)
|
||||
}
|
||||
user, token, session, err := h.auth.Register(
|
||||
c.Request.Context(),
|
||||
body.Email,
|
||||
username,
|
||||
body.Password,
|
||||
body.InviteCode,
|
||||
emailCode,
|
||||
clientIP(c),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.writeSession(c, token, session, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var body struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
identifier := strings.TrimSpace(body.Identifier)
|
||||
if identifier == "" {
|
||||
if strings.TrimSpace(body.Email) != "" {
|
||||
identifier = strings.TrimSpace(body.Email)
|
||||
} else {
|
||||
identifier = strings.TrimSpace(body.Username)
|
||||
}
|
||||
}
|
||||
if identifier == "" || body.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "账号或密码不能为空"})
|
||||
return
|
||||
}
|
||||
ip := clientIP(c)
|
||||
if err := h.enforceRateLimit(c, "auth:login:ip:"+ip, 20, 15*time.Minute); err != nil {
|
||||
return
|
||||
}
|
||||
if normalized, err := service.ValidateLoginIdentifier(identifier); err == nil {
|
||||
if err := h.enforceRateLimit(c, "auth:login:target:"+ip+":"+strings.ToLower(normalized), 8, 15*time.Minute); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, token, session, err := h.auth.Login(c.Request.Context(), identifier, body.Password, ip)
|
||||
if err != nil {
|
||||
if writeLoginLocked(c, err) {
|
||||
return
|
||||
}
|
||||
if err == service.ErrAuthFailed {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "账号或密码错误"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.writeSession(c, token, session, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
EmailCode string `json:"email_code"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
ip := clientIP(c)
|
||||
if err := h.enforceRateLimit(c, "auth:reset:ip:"+ip, 5, time.Hour); err != nil {
|
||||
return
|
||||
}
|
||||
if email, err := service.ValidateEmail(body.Email); err == nil {
|
||||
if err := h.enforceRateLimit(c, "auth:reset:email:"+email, 5, time.Hour); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
emailCode := strings.TrimSpace(body.EmailCode)
|
||||
if emailCode == "" {
|
||||
emailCode = strings.TrimSpace(body.Code)
|
||||
}
|
||||
if err := h.auth.ResetPassword(c.Request.Context(), body.Email, body.Password, emailCode, ip); err != nil {
|
||||
if writeLoginLocked(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
if err := h.enforceRateLimit(c, "auth:change-password:user:"+user.ID, 10, 30*time.Minute); err != nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
Current string `json:"current"`
|
||||
NewPassword string `json:"new_password"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
current := strings.TrimSpace(body.CurrentPassword)
|
||||
if current == "" {
|
||||
current = strings.TrimSpace(body.Current)
|
||||
}
|
||||
next := strings.TrimSpace(body.NewPassword)
|
||||
if next == "" {
|
||||
next = body.Password
|
||||
}
|
||||
if err := h.auth.ChangePassword(c.Request.Context(), user.ID, current, next); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Checkin(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
result, err := h.auth.Checkin(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"already": result.Already,
|
||||
"awarded": result.Awarded,
|
||||
"streak": result.Streak,
|
||||
"credits": result.Credits,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Invites(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
items, err := h.auth.InviteList(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load invites"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items, "reward": h.auth.InviteReward(c.Request.Context())})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
token := service.ParseBearer(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
token = readCookie(c, h.cfg.SessionCookieName)
|
||||
}
|
||||
_ = h.auth.Logout(c.Request.Context(), token)
|
||||
c.SetCookie(h.cfg.SessionCookieName, "", -1, "/", "", h.cfg.CookieSecure, true)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
userValue, ok := c.Get("current_user")
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
sessionValue, ok := c.Get("current_session")
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := userValue.(*model.User)
|
||||
session, _ := sessionValue.(*service.SessionPayload)
|
||||
if user == nil || session == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "账号或密码错误"})
|
||||
return
|
||||
}
|
||||
publicUser, err := h.auth.PublicUser(c.Request.Context(), user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load user profile"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"user": publicUser,
|
||||
})
|
||||
}
|
||||
|
||||
// writeLoginLocked maps a LoginGuard lockout error to HTTP 429 with a
|
||||
// Retry-After header (mirrors Python api/auth.py:226-237). Returns true when it
|
||||
// handled the error so the caller stops processing.
|
||||
func writeLoginLocked(c *gin.Context, err error) bool {
|
||||
var locked *service.LoginLockedError
|
||||
if errors.As(err, &locked) {
|
||||
c.Header("Retry-After", strconv.Itoa(locked.RetryAfter))
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": locked.Error()})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientIP(c *gin.Context) string {
|
||||
if fwd := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); fwd != "" {
|
||||
parts := strings.Split(fwd, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if real := strings.TrimSpace(c.GetHeader("X-Real-Ip")); real != "" {
|
||||
return real
|
||||
}
|
||||
return c.ClientIP()
|
||||
}
|
||||
|
||||
func (h *AuthHandler) enforceRateLimit(c *gin.Context, bucket string, limit int64, window time.Duration) error {
|
||||
if h.limiter == nil {
|
||||
return nil
|
||||
}
|
||||
if err := h.limiter.Enforce(c.Request.Context(), bucket, limit, window); err != nil {
|
||||
if errors.Is(err, service.ErrRateLimited) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
return err
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "rate limiter unavailable"})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) writeSession(c *gin.Context, token string, session *service.SessionPayload, user *model.User) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(h.cfg.SessionCookieName, token, int(h.cfg.SessionTTL.Seconds()), "/", "", h.cfg.CookieSecure, true)
|
||||
publicUser, err := h.auth.PublicUser(c.Request.Context(), user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load user profile"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"token": token,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"user": publicUser,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CDKHandler struct {
|
||||
cdks *service.CDKService
|
||||
}
|
||||
|
||||
func NewCDKHandler(cdks *service.CDKService) *CDKHandler {
|
||||
return &CDKHandler{cdks: cdks}
|
||||
}
|
||||
|
||||
func (h *CDKHandler) List(c *gin.Context) {
|
||||
items, stats, names, err := h.cdks.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load cdks"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": cdkPublic(items, names), "stats": stats})
|
||||
}
|
||||
|
||||
func (h *CDKHandler) Create(c *gin.Context) {
|
||||
var body struct {
|
||||
Amount int `json:"amount"`
|
||||
Count int `json:"count"`
|
||||
Note string `json:"note"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
items, err := h.cdks.Generate(c.Request.Context(), body.Amount, body.Count, body.Note, body.Type)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "created": cdkPublic(items, nil)})
|
||||
}
|
||||
|
||||
func (h *CDKHandler) Delete(c *gin.Context) {
|
||||
if err := h.cdks.Delete(c.Request.Context(), c.Param("code")); err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "cdk not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete cdk"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// DeleteBulk removes multiple CDK codes in one call (multi-select).
|
||||
func (h *CDKHandler) DeleteBulk(c *gin.Context) {
|
||||
var body struct {
|
||||
Codes []string `json:"codes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if len(body.Codes) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何兑换码"})
|
||||
return
|
||||
}
|
||||
n, err := h.cdks.DeleteBulk(c.Request.Context(), body.Codes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete cdks"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n})
|
||||
}
|
||||
|
||||
func cdkPublic(items []model.CDKCode, nameByID map[string]string) []gin.H {
|
||||
out := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
var redeemedByName any
|
||||
if item.RedeemedBy != nil && *item.RedeemedBy != "" {
|
||||
if name, ok := nameByID[*item.RedeemedBy]; ok {
|
||||
redeemedByName = name
|
||||
}
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"code": item.Code,
|
||||
"amount": item.Amount,
|
||||
"status": item.Status,
|
||||
"type": item.Type,
|
||||
"batch_id": item.BatchID,
|
||||
"note": item.Note,
|
||||
"redeemed_by": item.RedeemedBy,
|
||||
"redeemed_by_name": redeemedByName,
|
||||
"redeemed_at": unixSecPtr(item.RedeemedAt),
|
||||
"created_at": unixSec(item.CreatedAt),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package handler
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type HealthHandler struct{}
|
||||
|
||||
func NewHealthHandler() *HealthHandler {
|
||||
return &HealthHandler{}
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Handle(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"backend/internal/config"
|
||||
"backend/internal/service"
|
||||
"backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ImageHandler struct {
|
||||
cfg *config.Config
|
||||
imageAccess *service.ImageAccessService
|
||||
store *storage.Client
|
||||
}
|
||||
|
||||
func NewImageHandler(cfg *config.Config, imageAccess *service.ImageAccessService, store *storage.Client) *ImageHandler {
|
||||
return &ImageHandler{
|
||||
cfg: cfg,
|
||||
imageAccess: imageAccess,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// Serve gates access (public showcase images, or a logged-in cookie — a regular
|
||||
// user only their own images, an admin anyone's) and then PROXIES the object
|
||||
// from RustFS. Nothing is read from local disk; the RustFS endpoint is never
|
||||
// exposed to the client.
|
||||
func (h *ImageHandler) Serve(c *gin.Context) {
|
||||
user := c.Param("user")
|
||||
name := c.Param("name")
|
||||
|
||||
rel, err := h.imageAccess.Resolve(user, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid path"})
|
||||
return
|
||||
}
|
||||
|
||||
public, err := h.imageAccess.IsPublic(c.Request.Context(), rel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to authorize image"})
|
||||
return
|
||||
}
|
||||
if !public {
|
||||
authorized, err := h.imageAccess.IsAuthorized(
|
||||
c.Request.Context(),
|
||||
readCookie(c, h.cfg.SessionCookieName),
|
||||
user,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to authorize image"})
|
||||
return
|
||||
}
|
||||
if !authorized {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "需要登录后访问"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Forward Range so the browser can seek within videos.
|
||||
resp, err := h.store.Get(c.Request.Context(), rel, c.GetHeader("Range"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": "failed to fetch object"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "not found"})
|
||||
return
|
||||
}
|
||||
for _, hdr := range []string{"Content-Type", "Content-Length", "Accept-Ranges", "Content-Range", "Last-Modified", "ETag", "Cache-Control"} {
|
||||
if v := resp.Header.Get(hdr); v != "" {
|
||||
c.Header(hdr, v)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
func readCookie(c *gin.Context, name string) string {
|
||||
v, err := c.Cookie(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProviderAdminHandler struct {
|
||||
tokens *service.TokenService
|
||||
refresh *service.RefreshProfileService
|
||||
}
|
||||
|
||||
func NewProviderAdminHandler(tokens *service.TokenService, refresh *service.RefreshProfileService) *ProviderAdminHandler {
|
||||
return &ProviderAdminHandler{
|
||||
tokens: tokens,
|
||||
refresh: refresh,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokensList(c *gin.Context) {
|
||||
data, err := h.tokens.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load tokens"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokensCreate(c *gin.Context) {
|
||||
var body struct {
|
||||
Pool string `json:"pool"`
|
||||
Value string `json:"value"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.tokens.Add(c.Request.Context(), body.Pool, body.Value, body.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportChatGPTToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
token := body.AccessToken
|
||||
if token == "" {
|
||||
token = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportChatGPTToken(c.Request.Context(), token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportRunwayToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
token := body.AccessToken
|
||||
if token == "" {
|
||||
token = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportRunwayToken(c.Request.Context(), token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportKreaCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportImagineToken(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cred := body.Cookie
|
||||
if cred == "" {
|
||||
cred = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportImagineToken(c.Request.Context(), cred, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportLeonardoCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportLeonardoCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportAdobeCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, profile, err := h.tokens.ImportAdobeCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"profile_id": profile.ID,
|
||||
"id": item.ID,
|
||||
"status": item.Status,
|
||||
"pending": item.Status == "pending",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokenUpdate(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.tokens.Update(c.Request.Context(), c.Param("pool"), c.Param("id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokenDelete(c *gin.Context) {
|
||||
if err := h.tokens.Delete(c.Request.Context(), c.Param("pool"), c.Param("id")); err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "token not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// TokenDeleteBulk removes multiple accounts in one call (account multi-select).
|
||||
func (h *ProviderAdminHandler) TokenDeleteBulk(c *gin.Context) {
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if len(body.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何账号"})
|
||||
return
|
||||
}
|
||||
n, err := h.tokens.DeleteBulk(c.Request.Context(), body.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
|
||||
data, err := h.tokens.Accounts(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load accounts"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountQuota(c *gin.Context) {
|
||||
data, err := h.tokens.Quota(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountEmail(c *gin.Context) {
|
||||
data, err := h.tokens.Email(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshProfiles(c *gin.Context) {
|
||||
items, err := h.refresh.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load refresh profiles"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshNow(c *gin.Context) {
|
||||
if err := h.refresh.RefreshNow(c.Request.Context(), c.Param("profile_id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshUpdate(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.refresh.Update(c.Request.Context(), c.Param("profile_id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshDelete(c *gin.Context) {
|
||||
if err := h.refresh.Delete(c.Request.Context(), c.Param("profile_id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ShowcaseHandler struct {
|
||||
showcase *service.ShowcaseService
|
||||
}
|
||||
|
||||
func NewShowcaseHandler(showcase *service.ShowcaseService) *ShowcaseHandler {
|
||||
return &ShowcaseHandler{showcase: showcase}
|
||||
}
|
||||
|
||||
func (h *ShowcaseHandler) List(c *gin.Context) {
|
||||
grouped, err := h.showcase.Grouped(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
|
||||
return
|
||||
}
|
||||
out := gin.H{}
|
||||
for kind, items := range grouped {
|
||||
rows := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, gin.H{
|
||||
"id": item.ID,
|
||||
"kind": item.Kind,
|
||||
"title": item.Title,
|
||||
"subtitle": item.Subtitle,
|
||||
"prompt": item.Prompt,
|
||||
"gradient": item.Gradient,
|
||||
"span": item.Span,
|
||||
"image": item.Image,
|
||||
"weight": item.Weight,
|
||||
"created_at": item.CreatedAt,
|
||||
"updated_at": item.UpdatedAt,
|
||||
})
|
||||
}
|
||||
out[kind] = rows
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SiteHandler struct {
|
||||
site *service.SiteService
|
||||
}
|
||||
|
||||
func NewSiteHandler(site *service.SiteService) *SiteHandler {
|
||||
return &SiteHandler{site: site}
|
||||
}
|
||||
|
||||
func (h *SiteHandler) Public(c *gin.Context) {
|
||||
title, err := h.site.Title(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"title": title, "contact": h.site.Contact(c.Request.Context())})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SiteSettingsHandler struct {
|
||||
site *service.SiteService
|
||||
}
|
||||
|
||||
func NewSiteSettingsHandler(site *service.SiteService) *SiteSettingsHandler {
|
||||
return &SiteSettingsHandler{site: site}
|
||||
}
|
||||
|
||||
func (h *SiteSettingsHandler) Get(c *gin.Context) {
|
||||
title, err := h.site.Title(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"title": title, "contact": h.site.Contact(c.Request.Context())})
|
||||
}
|
||||
|
||||
func (h *SiteSettingsHandler) Put(c *gin.Context) {
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
Contact service.Contact `json:"contact"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(body.Title)
|
||||
if title == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "网页主标题不能为空"})
|
||||
return
|
||||
}
|
||||
updated, err := h.site.SetTitle(c.Request.Context(), title)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save site settings"})
|
||||
return
|
||||
}
|
||||
if err := h.site.SetContact(c.Request.Context(), body.Contact); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save contact info"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": gin.H{"title": updated, "contact": h.site.Contact(c.Request.Context())}})
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UserGenerationHandler struct {
|
||||
userGen *service.UserGenerationService
|
||||
admin *service.AdminReadService
|
||||
}
|
||||
|
||||
func NewUserGenerationHandler(userGen *service.UserGenerationService, admin *service.AdminReadService) *UserGenerationHandler {
|
||||
return &UserGenerationHandler{
|
||||
userGen: userGen,
|
||||
admin: admin,
|
||||
}
|
||||
}
|
||||
|
||||
// MyImages returns the current user's own recently generated images (scoped to
|
||||
// their owner directory) — used by the showcase "选择已生成" picker so an admin
|
||||
// only sees their own images, not everyone's.
|
||||
func (h *UserGenerationHandler) MyImages(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
items, err := h.admin.RecentImagesOwned(c.Request.Context(), service.OwnerDir(user), 60)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load images"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Generate(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Ratio string `json:"ratio"`
|
||||
Resolution string `json:"resolution"`
|
||||
Duration string `json:"duration"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.userGen.Generate(c.Request.Context(), user, service.UserGenerateRequest{
|
||||
Model: body.Model,
|
||||
Prompt: body.Prompt,
|
||||
Ratio: body.Ratio,
|
||||
Resolution: body.Resolution,
|
||||
Duration: body.Duration,
|
||||
ReferenceImages: body.ReferenceImages,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrUnsupportedParams):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"detail": "积分不足"})
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderAuth), errors.Is(err, service.ErrProviderTemporary):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderQuota):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrConcurrencyFull):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderExecution):
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
|
||||
default:
|
||||
if err.Error() == "已有正在生成的任务,请稍候" {
|
||||
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Test(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Ratio string `json:"ratio"`
|
||||
Resolution string `json:"resolution"`
|
||||
Duration string `json:"duration"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.userGen.AdminTest(c.Request.Context(), user, service.UserGenerateRequest{
|
||||
Model: body.Model,
|
||||
Prompt: body.Prompt,
|
||||
Ratio: body.Ratio,
|
||||
Resolution: body.Resolution,
|
||||
Duration: body.Duration,
|
||||
ReferenceImages: body.ReferenceImages,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrUnsupportedParams):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderQuota):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrConcurrencyFull):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderAuth), errors.Is(err, service.ErrProviderTemporary):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderExecution):
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) MyJobs(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"pending": nil, "latest": nil})
|
||||
return
|
||||
}
|
||||
data, err := h.userGen.MyJobs(c.Request.Context(), user, c.Query("source"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load jobs"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := parseInt(c.Query("limit"), 50)
|
||||
offset := parseInt(c.Query("offset"), 0)
|
||||
kind := c.Query("kind")
|
||||
status := c.Query("status")
|
||||
// Secure-by-default: always scope to the caller's OWN records. This endpoint
|
||||
// serves the front-end 日志 / 创作记录 pages, so an admin viewing their personal
|
||||
// records must NOT see other users' work. Only an admin who explicitly opts
|
||||
// into the full view (?scope=all — the admin 日志 page) sees everyone's logs.
|
||||
// API-key ("v1") usage IS included for the caller's own records so the user
|
||||
// can audit their key's calls on /mylogs; the image-only 创作记录 gallery still
|
||||
// hides them client-side (they have no stored file).
|
||||
userID := user.ID
|
||||
excludeSource := ""
|
||||
if user.Role == "admin" && c.Query("scope") == "all" {
|
||||
userID = ""
|
||||
}
|
||||
// 来源筛选: "v1" = API key, "user" = 前台画图, "admin" = 测试模型. 始终生效 ——
|
||||
// 普通用户已被 userID 限定为本人记录,按来源服务端筛选 + 分页(/mylogs 翻全部历史)。
|
||||
source := c.Query("source")
|
||||
// 创作记录 gallery passes has_file=1 so server-side pagination counts only
|
||||
// rows with real media (success + stored file), not failed/pending events.
|
||||
hasFile := c.Query("has_file") == "1" || c.Query("has_file") == "true"
|
||||
|
||||
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, userID, excludeSource, source, hasFile)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
}
|
||||
// Resolve user_id -> display name (mirrors admin.py / AdminReadHandler.Logs).
|
||||
// Without this the log table showed every row as "匿名".
|
||||
nameByID, err := h.admin.UserNameMap(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
var userName any
|
||||
if item.UserID == "" {
|
||||
userName = "匿名"
|
||||
} else if name, ok := nameByID[item.UserID]; ok {
|
||||
userName = name
|
||||
} else {
|
||||
userName = item.UserID
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": item.ID,
|
||||
"ts": item.TS.Unix(),
|
||||
"kind": item.Kind,
|
||||
"status": item.Status,
|
||||
"model": item.Model,
|
||||
"provider": item.Provider,
|
||||
"prompt": item.Prompt,
|
||||
"ratio": item.Ratio,
|
||||
"resolution": item.Resolution,
|
||||
"duration": item.Duration,
|
||||
"refs": item.Refs,
|
||||
"source": emptyStringNil(item.Source),
|
||||
"user_id": emptyStringNil(item.UserID),
|
||||
"user_name": userName,
|
||||
"cost": item.Cost,
|
||||
"elapsed_ms": item.ElapsedMS,
|
||||
"file": emptyStringNil(item.File),
|
||||
"error": emptyStringNil(item.Error),
|
||||
"created_at": unixSec(item.CreatedAt),
|
||||
"updated_at": unixSec(item.UpdatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": out,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []gin.H{
|
||||
{
|
||||
"key": "gemini-veo31",
|
||||
"label": "Veo31",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16x9", "9x16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "firefly-ray",
|
||||
"label": "Luma Ray",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"5s", "10s"},
|
||||
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "firefly-video",
|
||||
"label": "Firefly Video",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"5s"},
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"540p", "720p", "1080p"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "runway-gen4-turbo",
|
||||
"label": "Runway Gen-4 Turbo",
|
||||
"type": "video",
|
||||
"provider": "runway",
|
||||
"durations": []string{"5s", "10s"},
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"2K"},
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
// Runway is strictly image-to-video — a first-frame image is required
|
||||
// (no text2video), so the UI must block submit without one.
|
||||
"requires_reference": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
||||
items, err := h.catalogEntries(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load catalog"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": items,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Models(c *gin.Context) {
|
||||
items, err := h.publicModels()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) {
|
||||
items := []gin.H{
|
||||
{
|
||||
"id": "gpt-image-2",
|
||||
"provider": "chatgpt",
|
||||
"type": "image",
|
||||
// ChatGPT web backend only reliably produces 1K and honors a limited
|
||||
// ratio set; size params are advisory prompt hints. Mirrors the Python
|
||||
// reference (providers/chatgpt/provider.py) — do not offer 2K/4K.
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 3,
|
||||
"description": "ChatGPT image generation",
|
||||
},
|
||||
{
|
||||
"id": "firefly-gpt-image-2",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "Adobe Firefly GPT Image",
|
||||
},
|
||||
{
|
||||
"id": "firefly-image-5",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"description": "Adobe Firefly Image 5",
|
||||
},
|
||||
{
|
||||
"id": "flux-kontext-max",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 4,
|
||||
"description": "Adobe Flux Kontext Max",
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-2",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"image_to_image": true,
|
||||
"description": "Adobe Gemini Flash Nano Banana",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo31",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16x9", "9x16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
"description": "Veo31 video",
|
||||
},
|
||||
{
|
||||
"id": "firefly-ray",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"5s", "10s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
"description": "Luma Ray video",
|
||||
},
|
||||
{
|
||||
"id": "firefly-video",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"540p", "720p", "1080p"},
|
||||
"durations": []string{"5s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
"description": "Adobe Firefly Video",
|
||||
},
|
||||
{
|
||||
"id": "runway-gen4-turbo",
|
||||
"provider": "runway",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"2K"},
|
||||
"durations": []string{"5s", "10s"},
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
"description": "Runway Gen-4 Turbo video (图生视频)",
|
||||
},
|
||||
{
|
||||
"id": "seedream-4.5",
|
||||
"provider": "leonardo",
|
||||
"type": "image",
|
||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
||||
"resolutions": []string{"2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "Leonardo Seedream 4.5 (生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "flux-klein-2",
|
||||
"provider": "krea",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 4,
|
||||
"description": "Krea Flux Klein (生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5",
|
||||
"provider": "imagine",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"2K"},
|
||||
"max_reference_images": 0,
|
||||
"description": "Imagine 1.5 (文生图)",
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5pro",
|
||||
"provider": "imagine",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"4K"},
|
||||
"max_reference_images": 0,
|
||||
"description": "Imagine 1.5 Pro (文生图)",
|
||||
},
|
||||
}
|
||||
existing := map[string]bool{}
|
||||
if h.admin != nil {
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range models {
|
||||
existing[item.ID] = true
|
||||
}
|
||||
}
|
||||
for i := range items {
|
||||
items[i]["added"] = existing[items[i]["id"].(string)]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
||||
items := []gin.H{
|
||||
{
|
||||
"id": "gpt-image-2",
|
||||
"provider": "chatgpt",
|
||||
"kind": "image",
|
||||
// See catalogEntries — ChatGPT only reliably does 1K and a limited
|
||||
// ratio set; matches the Python reference. Keep both lists in sync.
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"description": "ChatGPT image generation",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-gpt-image-2",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Adobe Firefly GPT Image",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-image-5",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Adobe Firefly Image 5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "flux-kontext-max",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"description": "Adobe Flux Kontext Max",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-2",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"description": "Adobe Gemini Flash Nano Banana",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo31",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16x9", "9x16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Veo31 video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-ray",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Luma Ray video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-video",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"540p", "720p", "1080p"},
|
||||
"description": "Adobe Firefly Video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-gen4-turbo",
|
||||
"provider": "runway",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"2K"},
|
||||
"description": "Runway Gen-4 Turbo video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedream-4.5",
|
||||
"provider": "leonardo",
|
||||
"kind": "image",
|
||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
||||
"resolutions": []string{"2K", "4K"},
|
||||
"description": "Leonardo Seedream 4.5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "flux-klein-2",
|
||||
"provider": "krea",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Krea Flux Klein",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5",
|
||||
"provider": "imagine",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"2K"},
|
||||
"description": "Imagine 1.5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5pro",
|
||||
"provider": "imagine",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"4K"},
|
||||
"description": "Imagine 1.5 Pro",
|
||||
"stub": false,
|
||||
},
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UserToolsHandler struct {
|
||||
keys *service.APIKeyService
|
||||
cdks *service.CDKService
|
||||
}
|
||||
|
||||
func NewUserToolsHandler(keys *service.APIKeyService, cdks *service.CDKService) *UserToolsHandler {
|
||||
return &UserToolsHandler{
|
||||
keys: keys,
|
||||
cdks: cdks,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UserToolsHandler) APIKeyGet(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
data, err := h.keys.Current(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load api key"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *UserToolsHandler) APIKeyMint(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
data, err := h.keys.Mint(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to mint api key"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *UserToolsHandler) APIKeyDelete(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
if err := h.keys.Revoke(c.Request.Context(), user.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to revoke api key"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *UserToolsHandler) RedeemCDK(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
data, err := h.cdks.Redeem(c.Request.Context(), user.ID, body.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "amount": data["amount"], "credits": data["credits"]})
|
||||
}
|
||||
|
||||
func currentUser(c *gin.Context) *model.User {
|
||||
value, ok := c.Get("current_user")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
user, _ := value.(*model.User)
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type V1Handler struct {
|
||||
v1 *service.V1Service
|
||||
}
|
||||
|
||||
func NewV1Handler(v1 *service.V1Service) *V1Handler {
|
||||
return &V1Handler{v1: v1}
|
||||
}
|
||||
|
||||
func (h *V1Handler) Models(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
_ = principal
|
||||
|
||||
items, err := h.v1.ListModels(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": items,
|
||||
})
|
||||
}
|
||||
|
||||
// ImageGenerations — OpenAI POST /v1/images/generations (text-to-image only).
|
||||
// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier
|
||||
// are mapped server-side. Returns {created, data:[{b64_json}]}.
|
||||
func (h *V1Handler) ImageGenerations(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
N int `json:"n"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
ResponseFormat string `json:"response_format"`
|
||||
Background string `json:"background"`
|
||||
OutputFormat string `json:"output_format"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
||||
Model: body.Model,
|
||||
Prompt: body.Prompt,
|
||||
N: body.N,
|
||||
Size: body.Size,
|
||||
Quality: body.Quality,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
||||
}
|
||||
|
||||
// ImageEdits — OpenAI POST /v1/images/edits (image-to-image). multipart/form-data
|
||||
// only: image / image[] file uploads (+ optional mask), prompt, model, n, size,
|
||||
// quality. Files become reference images. Returns {created, data:[{b64_json}]}.
|
||||
func (h *V1Handler) ImageEdits(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires multipart/form-data"})
|
||||
return
|
||||
}
|
||||
if err := c.Request.ParseMultipartForm(64 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
refs := readMultipartImages(c, "image", "image[]")
|
||||
if len(refs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires at least one image file"})
|
||||
return
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("n")))
|
||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
||||
Model: c.PostForm("model"),
|
||||
Prompt: c.PostForm("prompt"),
|
||||
N: n,
|
||||
Size: c.PostForm("size"),
|
||||
Quality: c.PostForm("quality"),
|
||||
ReferenceImages: refs,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
||||
}
|
||||
|
||||
// CreateVideo — OpenAI POST /v1/videos. Creates an async job and returns the
|
||||
// video object immediately ({id, status:"queued"}). Accepts JSON {model, prompt,
|
||||
// seconds, size} or multipart (with an input_reference file). size→ratio+
|
||||
// resolution, seconds→duration.
|
||||
func (h *V1Handler) CreateVideo(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
var modelID, prompt, seconds, size string
|
||||
var refs []string
|
||||
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||
if err := c.Request.ParseMultipartForm(64 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
modelID = c.PostForm("model")
|
||||
prompt = c.PostForm("prompt")
|
||||
seconds = c.PostForm("seconds")
|
||||
size = c.PostForm("size")
|
||||
refs = readMultipartImages(c, "input_reference", "input_reference[]")
|
||||
} else {
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Seconds json.RawMessage `json:"seconds"`
|
||||
Size string `json:"size"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
modelID, prompt, size = body.Model, body.Prompt, body.Size
|
||||
seconds = rawToString(body.Seconds)
|
||||
}
|
||||
duration := strings.TrimSpace(seconds)
|
||||
if duration != "" && !strings.HasSuffix(duration, "s") {
|
||||
duration += "s"
|
||||
}
|
||||
aspect, resolution := videoSizeToInternal(size)
|
||||
resp, err := h.v1.StartVideoJob(c.Request.Context(), principal, service.V1VideoRequest{
|
||||
Model: modelID,
|
||||
Prompt: prompt,
|
||||
Duration: duration,
|
||||
AspectRatio: aspect,
|
||||
Resolution: resolution,
|
||||
ReferenceImages: refs,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetVideo — OpenAI GET /v1/videos/{id}. Returns the job status object.
|
||||
func (h *V1Handler) GetVideo(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.v1.VideoJob(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetVideoContent — OpenAI GET /v1/videos/{id}/content. Streams the rendered mp4
|
||||
// by proxying the stored upstream URL (downloaded on demand, never persisted).
|
||||
func (h *V1Handler) GetVideoContent(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
body, contentType, err := h.v1.OpenVideoContent(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, body)
|
||||
}
|
||||
|
||||
// readMultipartImages reads the given file fields and returns each as base64.
|
||||
func readMultipartImages(c *gin.Context, keys ...string) []string {
|
||||
var out []string
|
||||
form := c.Request.MultipartForm
|
||||
if form == nil {
|
||||
return out
|
||||
}
|
||||
for _, key := range keys {
|
||||
for _, fh := range form.File[key] {
|
||||
f, e := fh.Open()
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(f, 8<<20+1))
|
||||
f.Close()
|
||||
if len(b) > 0 {
|
||||
out = append(out, base64.StdEncoding.EncodeToString(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rawToString accepts OpenAI's `seconds` whether sent as a JSON string or number.
|
||||
func rawToString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var n json.Number
|
||||
if json.Unmarshal(raw, &n) == nil {
|
||||
return n.String()
|
||||
}
|
||||
return strings.Trim(string(raw), `"`)
|
||||
}
|
||||
|
||||
// videoSizeToInternal maps OpenAI's "WxH" size to our aspect ratio + resolution
|
||||
// tier (height ≥1080 → 1080p, else 720p).
|
||||
func videoSizeToInternal(size string) (ratio, resolution string) {
|
||||
var w, h int
|
||||
if s := strings.TrimSpace(strings.ToLower(size)); s != "" {
|
||||
_, _ = fmt.Sscanf(s, "%dx%d", &w, &h)
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
return "16:9", "720p"
|
||||
}
|
||||
long := w
|
||||
if h > long {
|
||||
long = h
|
||||
}
|
||||
resolution = "720p"
|
||||
if long >= 1080 {
|
||||
resolution = "1080p"
|
||||
}
|
||||
return guessRatioWH(w, h), resolution
|
||||
}
|
||||
|
||||
func guessRatioWH(w, h int) string {
|
||||
if w == h {
|
||||
return "1:1"
|
||||
}
|
||||
r := float64(w) / float64(h)
|
||||
cands := []struct {
|
||||
name string
|
||||
v float64
|
||||
}{{"16:9", 16.0 / 9}, {"9:16", 9.0 / 16}, {"4:3", 4.0 / 3}, {"3:4", 3.0 / 4}, {"1:1", 1}}
|
||||
best, bestD := "16:9", 1e9
|
||||
for _, cd := range cands {
|
||||
d := r - cd.v
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d < bestD {
|
||||
best, bestD = cd.name, d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// openaiImageResponse strips our rich internal map down to OpenAI's image shape.
|
||||
func openaiImageResponse(m map[string]any) gin.H {
|
||||
out := gin.H{"created": m["created"]}
|
||||
if d, ok := m["data"]; ok && d != nil {
|
||||
out["data"] = d
|
||||
} else {
|
||||
out["data"] = []any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *V1Handler) writeAuthError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMissingAPIKey):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInvalidAPIKey):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate api key"})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]any) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrUnsupportedParams):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrReferenceTooLarge):
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderAuth):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderQuota):
|
||||
// Match the Python contract: provider quota exhaustion maps to 401
|
||||
// (QuotaExhaustedError is handled alongside AuthError in routes.py).
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderTemporary):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrConcurrencyFull):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrVideoJobNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrVideoNotReady):
|
||||
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderUnsupported):
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderExecution):
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrGenerationPending):
|
||||
c.JSON(http.StatusNotImplemented, payload)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// requestBaseURL derives the scheme+host of the inbound request so the service
|
||||
// layer can build absolute, directly-downloadable output URLs. Honors
|
||||
// X-Forwarded-Proto (reverse-proxy / TLS termination) before falling back to
|
||||
// the connection's TLS state. Returns "" when the host is unknown, which makes
|
||||
// the service fall back to a relative path.
|
||||
func requestBaseURL(c *gin.Context) string {
|
||||
host := c.Request.Host
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
scheme := "http"
|
||||
if proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); proto != "" {
|
||||
scheme = strings.ToLower(strings.Split(proto, ",")[0])
|
||||
} else if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const currentUserKey = "current_user"
|
||||
const currentSessionKey = "current_session"
|
||||
|
||||
func RequireSession(auth *service.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, session, err := auth.CurrentUserFromRequest(
|
||||
c.Request.Context(),
|
||||
c.GetHeader("Authorization"),
|
||||
readCookie(c, "vivid_session"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate session"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if user == nil || session == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(currentUserKey, user)
|
||||
c.Set(currentSessionKey, session)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAdminSession(auth *service.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, session, err := auth.CurrentUserFromRequest(
|
||||
c.Request.Context(),
|
||||
c.GetHeader("Authorization"),
|
||||
readCookie(c, "vivid_session"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate session"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if user == nil || session == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "需要管理员权限"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(currentUserKey, user)
|
||||
c.Set(currentSessionKey, session)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func readCookie(c *gin.Context, name string) string {
|
||||
v, err := c.Cookie(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const requestIDKey = "request_id"
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reqID := c.GetHeader("X-Request-Id")
|
||||
if reqID == "" {
|
||||
reqID = uuid.NewString()
|
||||
}
|
||||
c.Set(requestIDKey, reqID)
|
||||
c.Writer.Header().Set("X-Request-Id", reqID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"backend/internal/config"
|
||||
"backend/internal/http/handler"
|
||||
"backend/internal/http/middleware"
|
||||
"backend/internal/service"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
Health *handler.HealthHandler
|
||||
Images *handler.ImageHandler
|
||||
V1 *handler.V1Handler
|
||||
Site *handler.SiteHandler
|
||||
Showcase *handler.ShowcaseHandler
|
||||
Auth *handler.AuthHandler
|
||||
SiteSettings *handler.SiteSettingsHandler
|
||||
AppSettings *handler.AppSettingsHandler
|
||||
AdminRead *handler.AdminReadHandler
|
||||
AdminWrite *handler.AdminWriteHandler
|
||||
CDK *handler.CDKHandler
|
||||
UserTools *handler.UserToolsHandler
|
||||
UserGen *handler.UserGenerationHandler
|
||||
ProviderAdmin *handler.ProviderAdminHandler
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine {
|
||||
if cfg.AppEnv != "development" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Recovery())
|
||||
engine.Use(middleware.RequestID())
|
||||
engine.Use(cors.New(cors.Config{
|
||||
AllowOrigins: cfg.CORSOrigins,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Authorization", "Content-Type", "X-Request-Id"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
engine.GET("/health", handlers.Health.Handle)
|
||||
engine.GET("/images/:user/:name", handlers.Images.Serve)
|
||||
engine.GET("/v1/models", handlers.V1.Models)
|
||||
engine.POST("/v1/images/generations", handlers.V1.ImageGenerations)
|
||||
engine.POST("/v1/images/edits", handlers.V1.ImageEdits)
|
||||
// OpenAI Sora-style async video: create job → poll → stream content.
|
||||
engine.POST("/v1/videos", handlers.V1.CreateVideo)
|
||||
engine.GET("/v1/videos/:id", handlers.V1.GetVideo)
|
||||
engine.GET("/v1/videos/:id/content", handlers.V1.GetVideoContent)
|
||||
|
||||
publicAdmin := engine.Group("/admin/api")
|
||||
{
|
||||
publicAdmin.GET("/site", handlers.Site.Public)
|
||||
publicAdmin.GET("/showcase", handlers.Showcase.List)
|
||||
publicAdmin.GET("/managed-models", handlers.AdminRead.Models)
|
||||
publicAdmin.GET("/stats", handlers.AdminRead.Stats)
|
||||
publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets)
|
||||
publicAdmin.GET("/catalog", handlers.UserGen.Catalog)
|
||||
publicAdmin.GET("/models", handlers.UserGen.Models)
|
||||
}
|
||||
|
||||
authGroup := engine.Group("/admin/api/auth")
|
||||
{
|
||||
authGroup.GET("/config", handlers.Auth.Config)
|
||||
authGroup.POST("/send-code", handlers.Auth.SendCode)
|
||||
authGroup.POST("/register", handlers.Auth.Register)
|
||||
authGroup.POST("/login", handlers.Auth.Login)
|
||||
authGroup.POST("/logout", handlers.Auth.Logout)
|
||||
authGroup.POST("/reset-password", handlers.Auth.ResetPassword)
|
||||
}
|
||||
|
||||
userAuthed := engine.Group("/admin/api")
|
||||
userAuthed.Use(middleware.RequireSession(auth))
|
||||
{
|
||||
userAuthed.GET("/logs", handlers.UserGen.Logs)
|
||||
userAuthed.POST("/generate", handlers.UserGen.Generate)
|
||||
userAuthed.POST("/test", handlers.UserGen.Test)
|
||||
userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs)
|
||||
userAuthed.GET("/my-images", handlers.UserGen.MyImages)
|
||||
}
|
||||
|
||||
authed := engine.Group("/admin/api")
|
||||
authed.Use(middleware.RequireAdminSession(auth))
|
||||
{
|
||||
authed.GET("/dashboard", handlers.AdminRead.Dashboard)
|
||||
authed.GET("/users", handlers.AdminRead.Users)
|
||||
authed.GET("/invites", handlers.AdminRead.Invites)
|
||||
authed.POST("/users", handlers.AdminWrite.CreateUser)
|
||||
authed.POST("/users/delete-bulk", handlers.AdminWrite.DeleteUsersBulk)
|
||||
authed.PATCH("/users/:user_id", handlers.AdminWrite.UpdateUser)
|
||||
authed.DELETE("/users/:user_id", handlers.AdminWrite.DeleteUser)
|
||||
authed.POST("/users/:user_id/credits", handlers.AdminWrite.AdjustUserCredits)
|
||||
authed.POST("/users/:user_id/api-keys", handlers.AdminWrite.CreateUserAPIKey)
|
||||
authed.DELETE("/users/:user_id/api-keys/:key_id", handlers.AdminWrite.DeleteUserAPIKey)
|
||||
authed.GET("/cdks", handlers.CDK.List)
|
||||
authed.POST("/cdks", handlers.CDK.Create)
|
||||
authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk)
|
||||
authed.DELETE("/cdks/:code", handlers.CDK.Delete)
|
||||
authed.GET("/tokens", handlers.ProviderAdmin.TokensList)
|
||||
authed.POST("/tokens", handlers.ProviderAdmin.TokensCreate)
|
||||
authed.POST("/tokens/import-chatgpt-token", handlers.ProviderAdmin.ImportChatGPTToken)
|
||||
authed.POST("/tokens/import-adobe-cookie", handlers.ProviderAdmin.ImportAdobeCookie)
|
||||
authed.POST("/tokens/import-runway-token", handlers.ProviderAdmin.ImportRunwayToken)
|
||||
authed.POST("/tokens/import-leonardo-cookie", handlers.ProviderAdmin.ImportLeonardoCookie)
|
||||
authed.POST("/tokens/import-krea-cookie", handlers.ProviderAdmin.ImportKreaCookie)
|
||||
authed.POST("/tokens/import-imagine-token", handlers.ProviderAdmin.ImportImagineToken)
|
||||
authed.POST("/tokens/delete-bulk", handlers.ProviderAdmin.TokenDeleteBulk)
|
||||
authed.PATCH("/tokens/:pool/:id", handlers.ProviderAdmin.TokenUpdate)
|
||||
authed.DELETE("/tokens/:pool/:id", handlers.ProviderAdmin.TokenDelete)
|
||||
authed.GET("/accounts", handlers.ProviderAdmin.AccountsList)
|
||||
authed.GET("/accounts/:pool/:id/quota", handlers.ProviderAdmin.AccountQuota)
|
||||
authed.GET("/accounts/:pool/:id/email", handlers.ProviderAdmin.AccountEmail)
|
||||
authed.GET("/providers", handlers.AdminRead.Providers)
|
||||
authed.GET("/images", handlers.AdminRead.Images)
|
||||
authed.GET("/refresh/profiles", handlers.ProviderAdmin.RefreshProfiles)
|
||||
authed.POST("/refresh/profiles/:profile_id/refresh-now", handlers.ProviderAdmin.RefreshNow)
|
||||
authed.PATCH("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshUpdate)
|
||||
authed.DELETE("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshDelete)
|
||||
authed.POST("/managed-models", handlers.AdminWrite.CreateModel)
|
||||
authed.PATCH("/managed-models/:model_id", handlers.AdminWrite.UpdateModel)
|
||||
authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
|
||||
authed.DELETE("/logs", handlers.AdminWrite.ClearLogs)
|
||||
authed.DELETE("/logs/pending", handlers.AdminWrite.ClearPendingLogs)
|
||||
authed.POST("/showcase", handlers.AdminWrite.CreateShowcase)
|
||||
authed.PATCH("/showcase/:entry_id", handlers.AdminWrite.UpdateShowcase)
|
||||
authed.DELETE("/showcase/:entry_id", handlers.AdminWrite.DeleteShowcase)
|
||||
|
||||
settings := authed.Group("/settings")
|
||||
{
|
||||
settings.GET("/site", handlers.SiteSettings.Get)
|
||||
settings.PUT("/site", handlers.SiteSettings.Put)
|
||||
settings.GET("/registration", handlers.AppSettings.RegistrationGet)
|
||||
settings.PUT("/registration", handlers.AppSettings.RegistrationPut)
|
||||
settings.GET("/smtp", handlers.AppSettings.SMTPGet)
|
||||
settings.PUT("/smtp", handlers.AppSettings.SMTPPut)
|
||||
settings.POST("/smtp/test", handlers.AppSettings.SMTPTest)
|
||||
settings.GET("/proxy", handlers.AppSettings.ProxyGet)
|
||||
settings.PUT("/proxy", handlers.AppSettings.ProxyPut)
|
||||
settings.POST("/proxy/test", handlers.AppSettings.ProxyTest)
|
||||
settings.GET("/credits", handlers.AppSettings.CreditsGet)
|
||||
settings.PUT("/credits", handlers.AppSettings.CreditsPut)
|
||||
settings.GET("/logs", handlers.AppSettings.LogsGet)
|
||||
settings.PUT("/logs", handlers.AppSettings.LogsPut)
|
||||
settings.GET("/media", handlers.AppSettings.MediaGet)
|
||||
settings.PUT("/media", handlers.AppSettings.MediaPut)
|
||||
}
|
||||
}
|
||||
|
||||
authGroup.Use(middleware.RequireSession(auth))
|
||||
{
|
||||
authGroup.GET("/me", handlers.Auth.Me)
|
||||
authGroup.GET("/invites", handlers.Auth.Invites)
|
||||
authGroup.POST("/checkin", handlers.Auth.Checkin)
|
||||
authGroup.POST("/change-password", handlers.Auth.ChangePassword)
|
||||
authGroup.GET("/api-key", handlers.UserTools.APIKeyGet)
|
||||
authGroup.POST("/api-key", handlers.UserTools.APIKeyMint)
|
||||
authGroup.DELETE("/api-key", handlers.UserTools.APIKeyDelete)
|
||||
authGroup.POST("/redeem-cdk", handlers.UserTools.RedeemCDK)
|
||||
}
|
||||
|
||||
return engine
|
||||
}
|
||||
Reference in New Issue
Block a user