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>
193 lines
4.9 KiB
Go
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)
|
|
}
|