Files
SakurasanandClaude Sonnet 5 d0e31b198f 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>
2026-08-15 21:05:02 +08:00

193 lines
4.9 KiB
Go

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)
}