feat(server): API relay gateway backend M0-M4
Gin + GORM + pure-Go SQLite. Users/auth (JWT), API key management with quotas, proxy gateway with weighted channel failover and health checks, usage/billing ledger, cross-protocol conversion (Anthropic Messages / OpenAI Chat Completions / OpenAI Responses), and channel/model admin API. Channels declare native API formats and auto-convert the rest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b0c7439c01
commit
d0e31b198f
@@ -0,0 +1,107 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"openteam/server/internal/config"
|
||||
"openteam/server/internal/pkg/httpx"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service, cfg *config.Config, log *zap.Logger) *Handler {
|
||||
return &Handler{svc: svc, cfg: cfg, log: log}
|
||||
}
|
||||
|
||||
// Register handles POST /api/v1/auth/register.
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var in RegisterInput
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
u, pair, err := h.svc.Register(in)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserExists) {
|
||||
httpx.Fail(c, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
h.log.Warn("register failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
httpx.Created(c, gin.H{"user": GetPublic(u), "token": pair})
|
||||
}
|
||||
|
||||
// Login handles POST /api/v1/auth/login.
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var in LoginInput
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
u, pair, err := h.svc.Login(in)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrBadCredentials) || errors.Is(err, ErrUserDisabled) {
|
||||
httpx.Fail(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
h.log.Warn("login failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "login failed")
|
||||
return
|
||||
}
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
httpx.OK(c, gin.H{"user": GetPublic(u), "token": pair})
|
||||
}
|
||||
|
||||
// Refresh handles POST /api/v1/auth/refresh.
|
||||
func (h *Handler) Refresh(c *gin.Context) {
|
||||
token, err := c.Cookie(h.cfg.Auth.RefreshCookieName)
|
||||
if err != nil || token == "" {
|
||||
httpx.Fail(c, http.StatusUnauthorized, "missing refresh token")
|
||||
return
|
||||
}
|
||||
pair, err := h.svc.Refresh(token)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusUnauthorized, "invalid refresh token")
|
||||
return
|
||||
}
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
httpx.OK(c, gin.H{"token": pair})
|
||||
}
|
||||
|
||||
// Logout handles POST /api/v1/auth/logout.
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
c.SetCookie(h.cfg.Auth.RefreshCookieName, "", -1, "/", "", h.cfg.Auth.RefreshCookieSecure, true)
|
||||
httpx.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Me handles GET /api/v1/auth/me.
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
u := Current(c)
|
||||
httpx.OK(c, GetPublic(u))
|
||||
}
|
||||
|
||||
// Profile handles GET /api/v1/user/profile.
|
||||
func (h *Handler) Profile(c *gin.Context) {
|
||||
u := Current(c)
|
||||
httpx.OK(c, GetPublic(u))
|
||||
}
|
||||
|
||||
// Balance handles GET /api/v1/user/balance.
|
||||
func (h *Handler) Balance(c *gin.Context) {
|
||||
u := Current(c)
|
||||
httpx.OK(c, gin.H{"balance": u.Balance.String()})
|
||||
}
|
||||
|
||||
func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
|
||||
c.SetCookie(h.cfg.Auth.RefreshCookieName, token,
|
||||
int(h.cfg.Auth.RefreshTokenTTL.Seconds()), "/", "", h.cfg.Auth.RefreshCookieSecure, true)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"openteam/server/internal/pkg/httpx"
|
||||
"openteam/server/internal/pkg/jwt"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
ctxUserKey = "current_user"
|
||||
ctxClaimsKey = "current_claims"
|
||||
)
|
||||
|
||||
// Current returns the authenticated user (set by Middleware).
|
||||
func Current(c *gin.Context) *store.User {
|
||||
if v, ok := c.Get(ctxUserKey); ok {
|
||||
if u, ok := v.(*store.User); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Claims returns the JWT claims of the current request.
|
||||
func Claims(c *gin.Context) *jwt.Claims {
|
||||
if v, ok := c.Get(ctxClaimsKey); ok {
|
||||
if cl, ok := v.(*jwt.Claims); ok {
|
||||
return cl
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Middleware authenticates the management API via an access token.
|
||||
func (s *Service) Middleware(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
httpx.Fail(c, http.StatusUnauthorized, "missing bearer token")
|
||||
return
|
||||
}
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
claims, err := jwt.Parse(secret, token)
|
||||
if err != nil || claims.Type != "access" {
|
||||
httpx.Fail(c, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
if err := s.db.First(&u, claims.UserID).Error; err != nil || u.Status != "active" {
|
||||
httpx.Fail(c, http.StatusUnauthorized, "user not found or disabled")
|
||||
return
|
||||
}
|
||||
c.Set(ctxUserKey, &u)
|
||||
c.Set(ctxClaimsKey, claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin guards admin-only routes.
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
u := Current(c)
|
||||
if u == nil || u.Role != "admin" {
|
||||
httpx.Fail(c, http.StatusForbidden, "admin only")
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/config"
|
||||
"openteam/server/internal/pkg/jwt"
|
||||
"openteam/server/internal/pkg/password"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserExists = errors.New("username or email already exists")
|
||||
ErrBadCredentials = errors.New("invalid username/email or password")
|
||||
ErrUserDisabled = errors.New("account disabled")
|
||||
ErrInvalidRefresh = errors.New("invalid refresh token")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, cfg *config.Config, log *zap.Logger) *Service {
|
||||
return &Service{db: db, cfg: cfg, log: log}
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=64"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
}
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
TokenType string `json:"tokenType"`
|
||||
ExpiresIn int64 `json:"expiresIn"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
// Register creates a user. Returns a token pair plus the created user.
|
||||
func (s *Service) Register(in RegisterInput) (*store.User, *TokenPair, error) {
|
||||
if s.cfg.Registration.Mode == "invite" {
|
||||
// Invite mode: validate the invite code before allowing signup.
|
||||
ok, err := s.validateInviteCode(in.InviteCode)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, nil, errors.New("invalid invite code")
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := password.Hash(in.Password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
u := &store.User{
|
||||
Username: in.Username,
|
||||
Email: in.Email,
|
||||
PasswordHash: hash,
|
||||
Role: "user",
|
||||
Balance: decimalZero(),
|
||||
Status: "active",
|
||||
InviteCode: in.InviteCode,
|
||||
}
|
||||
if err := s.db.Create(u).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return nil, nil, ErrUserExists
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
pair, err := s.issuePair(u)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return u, pair, nil
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Account string `json:"account" binding:"required"` // username or email
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// Login authenticates by username or email and returns a token pair.
|
||||
func (s *Service) Login(in LoginInput) (*store.User, *TokenPair, error) {
|
||||
var u store.User
|
||||
err := s.db.Where("username = ? OR email = ?", in.Account, in.Account).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, ErrBadCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ok, err := password.Verify(in.Password, u.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
return nil, nil, ErrBadCredentials
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, nil, ErrUserDisabled
|
||||
}
|
||||
now := time.Now()
|
||||
u.LastLoginAt = &now
|
||||
s.db.Model(&u).Update("last_login_at", now)
|
||||
pair, err := s.issuePair(&u)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &u, pair, nil
|
||||
}
|
||||
|
||||
// Refresh issues a new access token from a valid refresh token.
|
||||
func (s *Service) Refresh(refreshToken string) (*TokenPair, error) {
|
||||
claims, err := jwt.Parse(s.cfg.Auth.JWTSecret, refreshToken)
|
||||
if err != nil || claims.Type != "refresh" {
|
||||
return nil, ErrInvalidRefresh
|
||||
}
|
||||
var u store.User
|
||||
if err := s.db.First(&u, claims.UserID).Error; err != nil {
|
||||
return nil, ErrInvalidRefresh
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrUserDisabled
|
||||
}
|
||||
return s.issuePair(&u)
|
||||
}
|
||||
|
||||
// GetByID loads a user.
|
||||
func (s *Service) GetByID(id int64) (*store.User, error) {
|
||||
var u store.User
|
||||
if err := s.db.First(&u, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetPublic returns a user DTO without secrets.
|
||||
func GetPublic(u *store.User) map[string]any {
|
||||
return map[string]any{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"email": u.Email,
|
||||
"role": u.Role,
|
||||
"balance": u.Balance.String(),
|
||||
"status": u.Status,
|
||||
"createdAt": u.CreatedAt,
|
||||
"lastLoginAt": u.LastLoginAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) validateInviteCode(code string) (bool, error) {
|
||||
var conf store.SystemConfig
|
||||
if err := s.db.First(&conf, "key = ?", "invite_codes").Error; err != nil {
|
||||
return false, nil
|
||||
}
|
||||
var codes map[string]bool
|
||||
if len(conf.Value) > 0 {
|
||||
if err := json.Unmarshal(conf.Value, &codes); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return codes[code], nil
|
||||
}
|
||||
|
||||
func (s *Service) issuePair(u *store.User) (*TokenPair, error) {
|
||||
access, err := jwt.SignAccess(s.cfg.Auth.JWTSecret, u.ID, u.Username, u.Role, s.cfg.Auth.AccessTokenTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refresh, err := jwt.SignRefresh(s.cfg.Auth.JWTSecret, u.ID, s.cfg.Auth.RefreshTokenTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenPair{
|
||||
AccessToken: access,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(s.cfg.Auth.AccessTokenTTL.Seconds()),
|
||||
RefreshToken: refresh,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decimalZero() decimal.Decimal {
|
||||
return decimal.NewFromInt(0)
|
||||
}
|
||||
Reference in New Issue
Block a user