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,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