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:
Sakurasan
2026-08-15 21:05:02 +08:00
co-authored by Claude Sonnet 5
parent b0c7439c01
commit d0e31b198f
45 changed files with 6222 additions and 0 deletions
+107
View File
@@ -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)
}
+74
View File
@@ -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()
}
}
+192
View File
@@ -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)
}