- 回退渠道 homepage/favicon 字段与代理接口(未采用) - Base URL 改为可选: 留空按供应商默认(openai/anthropic), 兼容兼容型渠道必须填; 填完整地址(含 /v1)时归一化去尾 - 网关 upstreamURL 兜底去 /v1, 避免路径重复 Co-Authored-By: Claude <noreply@anthropic.com>
208 lines
6.2 KiB
Go
208 lines
6.2 KiB
Go
// Package api 管理 API(/api/v1):认证、用户、密钥、用量、管理后台。
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/openteam/server/internal/app"
|
||
"github.com/openteam/server/internal/api/middleware"
|
||
"github.com/openteam/server/internal/pkg/resp"
|
||
"github.com/openteam/server/internal/store"
|
||
)
|
||
|
||
// Handler 聚合所有管理 API。
|
||
type Handler struct {
|
||
a *app.App
|
||
}
|
||
|
||
func NewHandler(a *app.App) *Handler { return &Handler{a: a} }
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 认证
|
||
|
||
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})
|
||
}
|
||
|
||
// 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,
|
||
"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
|
||
}
|