// Package api 管理 API(/api/v1):认证、用户、密钥、用量、管理后台。 package api import ( "encoding/json" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/openteam/server/internal/api/middleware" "github.com/openteam/server/internal/app" "github.com/openteam/server/internal/passkey" "github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/proxy" "github.com/openteam/server/internal/store" ) // Handler 聚合所有管理 API。 type Handler struct { a *app.App gw *proxy.Gateway passkeys *passkey.Service } func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler { return &Handler{a: a, gw: gw, passkeys: pk} } // --------------------------------------------------------------------------- // 认证 type registerReq struct { Username string `json:"username" binding:"required,min=3,max=32"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8,max=72"` InviteCode string `json:"invite_code"` } // Register POST /api/v1/auth/register func (h *Handler) Register(c *gin.Context) { var req registerReq if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error()) return } // 注册模式优先读系统配置(管理后台可改),缺省用环境配置 mode := h.a.Cfg.Auth.RegistrationMode var modeCfgRaw string h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "registration_mode").Pluck("value", &modeCfgRaw) var modeCfg string _ = json.Unmarshal([]byte(modeCfgRaw), &modeCfg) if modeCfg == "open" || modeCfg == "invite" { mode = modeCfg } if mode == "invite" { var icRaw string h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &icRaw) var ic string _ = json.Unmarshal([]byte(icRaw), &ic) if req.InviteCode == "" || !strings.Contains(ic, req.InviteCode) { resp.Fail(c, http.StatusForbidden, "valid invite code required") return } } var count int64 h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, req.Email).Count(&count) if count > 0 { resp.Fail(c, http.StatusConflict, "username or email already exists") return } hash, err := h.a.Hasher.HashPassword(req.Password) if err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to hash password") return } u := store.User{ Username: req.Username, Email: strings.ToLower(req.Email), PasswordHash: hash, Role: store.RoleUser, Balance: 5.0, // 新用户赠送体验余额(可通过 admin 调整) Status: store.UserStatusActive, } if req.InviteCode != "" { ic := req.InviteCode u.InviteCode = &ic } if err := h.a.DB.Create(&u).Error; err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to create user") return } resp.Created(c, gin.H{"id": u.ID, "username": u.Username}) } type loginReq struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` } // Login POST /api/v1/auth/login — 返回 access token,refresh token 写入 HttpOnly Cookie。 func (h *Handler) Login(c *gin.Context) { var req loginReq if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, http.StatusBadRequest, "invalid input") return } var u store.User err := h.a.DB.Where("username = ? OR email = ?", req.Username, strings.ToLower(req.Username)).First(&u).Error if err != nil || u.Status != store.UserStatusActive { resp.Fail(c, http.StatusUnauthorized, "invalid credentials") return } ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.Password) if err != nil || !ok { resp.Fail(c, http.StatusUnauthorized, "invalid credentials") return } now := time.Now() h.a.DB.Model(&u).Update("last_login_at", now) access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access") if err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to issue token") return } refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh") if err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to issue token") return } h.setRefreshCookie(c, refresh) resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds()), "user": h.publicUser(&u)}) } // Refresh POST /api/v1/auth/refresh — 用 refresh cookie 换新 access token。 func (h *Handler) Refresh(c *gin.Context) { tok, err := c.Cookie(h.a.Cfg.JWT.CookieName) if err != nil || tok == "" { resp.Fail(c, http.StatusUnauthorized, "refresh token missing") return } claims, err := h.a.JWT.Parse(tok) if err != nil || claims.Subject != "refresh" { resp.Fail(c, http.StatusUnauthorized, "invalid refresh token") return } var u store.User if err := h.a.DB.First(&u, claims.UserID).Error; err != nil || u.Status != store.UserStatusActive { resp.Fail(c, http.StatusUnauthorized, "user not found or disabled") return } access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access") if err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to issue token") return } resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds())}) } // Logout POST /api/v1/auth/logout — 清除 refresh cookie。 func (h *Handler) Logout(c *gin.Context) { h.setRefreshCookie(c, "") resp.OK(c, gin.H{"ok": true}) } type changePasswordReq struct { OldPassword string `json:"old_password" binding:"required"` NewPassword string `json:"new_password" binding:"required,min=8,max=72"` } // ChangePassword POST /api/v1/auth/password — 修改密码。 func (h *Handler) ChangePassword(c *gin.Context) { u := sessionUser(c) var req changePasswordReq if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error()) return } ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.OldPassword) if err != nil || !ok { resp.Fail(c, http.StatusBadRequest, "旧密码不正确") return } hash, err := h.a.Hasher.HashPassword(req.NewPassword) if err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to hash password") return } if err := h.a.DB.Model(&store.User{}).Where("id = ?", u.ID).Update("password_hash", hash).Error; err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to update password") return } resp.OK(c, gin.H{"ok": true}) } // Me GET /api/v1/auth/me func (h *Handler) Me(c *gin.Context) { u := sessionUser(c) resp.OK(c, gin.H{"user": h.publicUser(u)}) } func (h *Handler) setRefreshCookie(c *gin.Context, value string) { maxAge := int(h.a.JWT.RefreshTTL().Seconds()) if value == "" { maxAge = -1 } http.SetCookie(c.Writer, &http.Cookie{ Name: h.a.Cfg.JWT.CookieName, Value: value, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: h.a.Cfg.JWT.CookieSecure, SameSite: http.SameSiteLaxMode, Domain: h.a.Cfg.JWT.CookieDomain, }) } func (h *Handler) publicUser(u *store.User) gin.H { return gin.H{ "id": u.ID, "username": u.Username, "email": u.Email, "role": u.Role, "balance": u.Balance, "status": u.Status, "allowed_models": u.AllowedModels, "denied_models": u.DeniedModels, "created_at": u.CreatedAt, } } func sessionUser(c *gin.Context) *store.User { u, _ := c.Get(middleware.CtxSessionUser) return u.(*store.User) } func userFromContext(c *gin.Context) (*store.User, bool) { u, ok := c.Get(middleware.CtxSessionUser) if !ok { return nil, false } return u.(*store.User), true }