Skip to content

Commit 4fb81ec

Browse files
authored
feat: resolve #310-新增邀请码功能 (#342)
* feat: add invite code flow and update safeguards - add invite code management and auth flow\n- scope invite code updates by tenant\n- handle update no-op and 404 cases\n- remove unused eslint disable in streaming hook * fix: invite code access control for members * feat: add invite code auth flow * fix: adjust tests and ui * fix: improve invite code usage handling * fix: refine invite code handling * fix: harden invite code flow and forms * fix: adjust invite code handlers * fix: align invite code enforcement
1 parent dc8c03b commit 4fb81ec

40 files changed

+3528
-190
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,7 @@ Thumbs.db
4545

4646
.claude/
4747
wailsjs/
48+
49+
AGENTS.md
50+
51+
.logs/

cmd/maxx/main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010
"os/exec"
1111
"os/signal"
1212
"path/filepath"
13-
"syscall"
1413
"sync/atomic"
14+
"syscall"
1515
"time"
1616

1717
"github.com/awsl-project/maxx/internal/adapter/client"
@@ -113,6 +113,8 @@ func main() {
113113
modelPriceRepo := sqlite.NewModelPriceRepository(db)
114114
tenantRepo := sqlite.NewTenantRepository(db)
115115
userRepo := sqlite.NewUserRepository(db)
116+
inviteCodeRepo := sqlite.NewInviteCodeRepository(db)
117+
inviteCodeUsageRepo := sqlite.NewInviteCodeUsageRepository(db)
116118

117119
// Initialize cooldown manager with database persistence
118120
cooldown.Default().SetRepository(cooldownRepo)
@@ -295,6 +297,8 @@ func main() {
295297
attemptRepo,
296298
settingRepo,
297299
cachedAPITokenRepo,
300+
inviteCodeRepo,
301+
inviteCodeUsageRepo,
298302
cachedModelMappingRepo,
299303
usageStatsRepo,
300304
responseModelRepo,
@@ -353,7 +357,15 @@ func main() {
353357
proxyHandler.SetRequestTracker(requestTracker)
354358
adminHandler := handler.NewAdminHandler(adminService, backupService, logPath)
355359
adminHandler.SetUserRepo(userRepo)
356-
authHandler := handler.NewAuthHandler(authMiddleware, userRepo, tenantRepo, authEnabled)
360+
adminHandler.SetAuthEnabled(authEnabled)
361+
authHandler := handler.NewAuthHandler(
362+
authMiddleware,
363+
userRepo,
364+
tenantRepo,
365+
inviteCodeRepo,
366+
inviteCodeUsageRepo,
367+
authEnabled,
368+
)
357369
antigravityHandler := handler.NewAntigravityHandler(adminService, antigravityQuotaRepo, wsHub)
358370
antigravityHandler.SetTaskService(antigravityTaskSvc)
359371
kiroHandler := handler.NewKiroHandler(adminService)

internal/core/database.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"time"
99

1010
"github.com/awsl-project/maxx/internal/adapter/client"
11-
"golang.org/x/crypto/bcrypt"
1211
_ "github.com/awsl-project/maxx/internal/adapter/provider/claude" // Register claude adapter
1312
_ "github.com/awsl-project/maxx/internal/adapter/provider/codex"
1413
_ "github.com/awsl-project/maxx/internal/adapter/provider/custom"
@@ -26,6 +25,7 @@ import (
2625
"github.com/awsl-project/maxx/internal/service"
2726
"github.com/awsl-project/maxx/internal/stats"
2827
"github.com/awsl-project/maxx/internal/waiter"
28+
"golang.org/x/crypto/bcrypt"
2929
)
3030

3131
// DatabaseConfig 数据库配置
@@ -67,6 +67,8 @@ type DatabaseRepos struct {
6767
ModelPriceRepo repository.ModelPriceRepository
6868
TenantRepo repository.TenantRepository
6969
UserRepo repository.UserRepository
70+
InviteCodeRepo repository.InviteCodeRepository
71+
InviteCodeUsageRepo repository.InviteCodeUsageRepository
7072
}
7173

7274
// ServerComponents 包含服务器运行所需的所有组件
@@ -131,6 +133,8 @@ func InitializeDatabase(config *DatabaseConfig) (*DatabaseRepos, error) {
131133
modelPriceRepo := sqlite.NewModelPriceRepository(db)
132134
tenantRepo := sqlite.NewTenantRepository(db)
133135
userRepo := sqlite.NewUserRepository(db)
136+
inviteCodeRepo := sqlite.NewInviteCodeRepository(db)
137+
inviteCodeUsageRepo := sqlite.NewInviteCodeUsageRepository(db)
134138

135139
log.Printf("[Core] Creating cached repositories")
136140

@@ -173,6 +177,8 @@ func InitializeDatabase(config *DatabaseConfig) (*DatabaseRepos, error) {
173177
ModelPriceRepo: modelPriceRepo,
174178
TenantRepo: tenantRepo,
175179
UserRepo: userRepo,
180+
InviteCodeRepo: inviteCodeRepo,
181+
InviteCodeUsageRepo: inviteCodeUsageRepo,
176182
}
177183

178184
log.Printf("[Core] Database initialized successfully")
@@ -336,6 +342,8 @@ func InitializeServerComponents(
336342
repos.AttemptRepo,
337343
repos.SettingRepo,
338344
repos.CachedAPITokenRepo,
345+
repos.InviteCodeRepo,
346+
repos.InviteCodeUsageRepo,
339347
repos.CachedModelMappingRepo,
340348
repos.UsageStatsRepo,
341349
repos.ResponseModelRepo,
@@ -372,7 +380,14 @@ func InitializeServerComponents(
372380
} else {
373381
log.Println("Admin API authentication is disabled (no MAXX_ADMIN_PASSWORD set)")
374382
}
375-
authHandler := handler.NewAuthHandler(authMiddleware, repos.UserRepo, repos.TenantRepo, authEnabled)
383+
authHandler := handler.NewAuthHandler(
384+
authMiddleware,
385+
repos.UserRepo,
386+
repos.TenantRepo,
387+
repos.InviteCodeRepo,
388+
repos.InviteCodeUsageRepo,
389+
authEnabled,
390+
)
376391

377392
log.Printf("[Core] Creating handlers")
378393
tokenAuthMiddleware := handler.NewTokenAuthMiddleware(repos.CachedAPITokenRepo, repos.SettingRepo)
@@ -384,6 +399,7 @@ func InitializeServerComponents(
384399
)
385400
adminHandler := handler.NewAdminHandler(adminService, backupService, logPath)
386401
adminHandler.SetUserRepo(repos.UserRepo)
402+
adminHandler.SetAuthEnabled(authEnabled)
387403
antigravityHandler := handler.NewAntigravityHandler(adminService, repos.AntigravityQuotaRepo, wailsBroadcaster)
388404
kiroHandler := handler.NewKiroHandler(adminService)
389405
codexHandler := handler.NewCodexHandler(adminService, repos.CodexQuotaRepo, wailsBroadcaster)

internal/domain/errors.go

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,69 @@
11
package domain
22

33
import (
4-
"errors"
5-
"fmt"
6-
"time"
4+
"errors"
5+
"fmt"
6+
"time"
77
)
88

99
var (
10-
ErrNotFound = errors.New("not found")
11-
ErrAlreadyExists = errors.New("already exists")
12-
ErrSlugExists = errors.New("slug already exists")
13-
ErrInvalidInput = errors.New("invalid input")
14-
ErrNoRoutes = errors.New("no routes available")
15-
ErrAllRoutesFailed = errors.New("all routes failed")
16-
ErrFirstByteTimeout = errors.New("first byte timeout")
17-
ErrStreamIdleTimeout = errors.New("stream idle timeout")
18-
ErrUpstreamError = errors.New("upstream error")
19-
ErrFormatConversion = errors.New("format conversion error")
20-
ErrUnsupportedFormat = errors.New("unsupported format")
10+
ErrNotFound = errors.New("not found")
11+
ErrAlreadyExists = errors.New("already exists")
12+
ErrSlugExists = errors.New("slug already exists")
13+
ErrInvalidInput = errors.New("invalid input")
14+
ErrInvalidState = errors.New("invalid state")
15+
ErrNoRoutes = errors.New("no routes available")
16+
ErrAllRoutesFailed = errors.New("all routes failed")
17+
ErrFirstByteTimeout = errors.New("first byte timeout")
18+
ErrStreamIdleTimeout = errors.New("stream idle timeout")
19+
ErrUpstreamError = errors.New("upstream error")
20+
ErrFormatConversion = errors.New("format conversion error")
21+
ErrUnsupportedFormat = errors.New("unsupported format")
22+
ErrInviteCodeRequired = errors.New("invite code required")
23+
ErrInviteCodeInvalid = errors.New("invite code invalid")
24+
ErrInviteCodeExpired = errors.New("invite code expired")
25+
ErrInviteCodeExhausted = errors.New("invite code exhausted")
26+
ErrInviteCodeDisabled = errors.New("invite code disabled")
2127
)
2228

2329
// ProxyError represents an error during proxy execution
2430
type ProxyError struct {
25-
Err error
26-
Retryable bool
27-
Message string
28-
RetryAfter time.Duration // Suggested retry delay (from 429 responses)
29-
CooldownUntil *time.Time // Absolute cooldown end time
30-
CooldownClientType string // ClientType for cooldown (empty = all client types)
31-
CooldownUpdateChan chan time.Time // Channel for async cooldown updates (optional)
32-
RateLimitInfo *RateLimitInfo // Additional rate limit information
33-
IsServerError bool // True for 5xx errors (triggers incremental cooldown)
34-
IsNetworkError bool // True for network errors (connection timeout, DNS failure, etc.)
35-
HTTPStatusCode int // HTTP status code (for logging and error handling)
31+
Err error
32+
Retryable bool
33+
Message string
34+
RetryAfter time.Duration // Suggested retry delay (from 429 responses)
35+
CooldownUntil *time.Time // Absolute cooldown end time
36+
CooldownClientType string // ClientType for cooldown (empty = all client types)
37+
CooldownUpdateChan chan time.Time // Channel for async cooldown updates (optional)
38+
RateLimitInfo *RateLimitInfo // Additional rate limit information
39+
IsServerError bool // True for 5xx errors (triggers incremental cooldown)
40+
IsNetworkError bool // True for network errors (connection timeout, DNS failure, etc.)
41+
HTTPStatusCode int // HTTP status code (for logging and error handling)
3642
}
3743

3844
// RateLimitInfo contains detailed rate limit information from providers
3945
type RateLimitInfo struct {
40-
Type string // Type of rate limit: "quota_exhausted", "rate_limit_exceeded", "concurrent", etc.
41-
QuotaResetTime time.Time // When quota resets (for quota exhaustion)
42-
RetryHintMessage string // Original error message with retry hints
43-
ClientType string // Affected client type (empty = all)
46+
Type string // Type of rate limit: "quota_exhausted", "rate_limit_exceeded", "concurrent", etc.
47+
QuotaResetTime time.Time // When quota resets (for quota exhaustion)
48+
RetryHintMessage string // Original error message with retry hints
49+
ClientType string // Affected client type (empty = all)
4450
}
4551

4652
func (e *ProxyError) Error() string {
47-
if e.Message != "" {
48-
return fmt.Sprintf("%s: %v", e.Message, e.Err)
49-
}
50-
return e.Err.Error()
53+
if e.Message != "" {
54+
return fmt.Sprintf("%s: %v", e.Message, e.Err)
55+
}
56+
return e.Err.Error()
5157
}
5258

5359
func (e *ProxyError) Unwrap() error {
54-
return e.Err
60+
return e.Err
5561
}
5662

5763
func NewProxyError(err error, retryable bool) *ProxyError {
58-
return &ProxyError{Err: err, Retryable: retryable}
64+
return &ProxyError{Err: err, Retryable: retryable}
5965
}
6066

6167
func NewProxyErrorWithMessage(err error, retryable bool, msg string) *ProxyError {
62-
return &ProxyError{Err: err, Retryable: retryable, Message: msg}
68+
return &ProxyError{Err: err, Retryable: retryable, Message: msg}
6369
}

internal/domain/invite_code.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package domain
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"strings"
7+
"time"
8+
"unicode"
9+
)
10+
11+
// InviteCodeStatus represents the status of an invite code.
12+
type InviteCodeStatus string
13+
14+
const (
15+
InviteCodeStatusActive InviteCodeStatus = "active"
16+
InviteCodeStatusDisabled InviteCodeStatus = "disabled"
17+
)
18+
19+
// InviteCodeInvalidPrefix is returned when a code cannot be normalized.
20+
const InviteCodeInvalidPrefix = "<invalid-invite>"
21+
22+
// InviteCode represents an invitation code used for registration.
23+
type InviteCode struct {
24+
ID uint64 `json:"id"`
25+
CreatedAt time.Time `json:"createdAt"`
26+
UpdatedAt time.Time `json:"updatedAt"`
27+
DeletedAt *time.Time `json:"deletedAt,omitempty"`
28+
29+
TenantID uint64 `json:"tenantID"`
30+
31+
CodeHash string `json:"-"`
32+
CodePrefix string `json:"codePrefix"`
33+
Status InviteCodeStatus `json:"status"`
34+
35+
MaxUses uint64 `json:"maxUses"`
36+
UsedCount uint64 `json:"usedCount"`
37+
38+
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
39+
40+
CreatedByUserID uint64 `json:"createdByUserID"`
41+
Note string `json:"note,omitempty"`
42+
}
43+
44+
// InviteCodeUsage records a usage event for an invite code.
45+
type InviteCodeUsage struct {
46+
ID uint64 `json:"id"`
47+
CreatedAt time.Time `json:"createdAt"`
48+
UpdatedAt time.Time `json:"updatedAt"`
49+
50+
TenantID uint64 `json:"tenantID"`
51+
InviteCodeID uint64 `json:"inviteCodeID"`
52+
UserID uint64 `json:"userID"`
53+
Username string `json:"username"`
54+
UsedAt time.Time `json:"usedAt"`
55+
56+
IP string `json:"ip"`
57+
UserAgent string `json:"userAgent"`
58+
59+
Result string `json:"result"`
60+
Reason string `json:"reason,omitempty"`
61+
RolledBack bool `json:"rolledBack,omitempty"`
62+
}
63+
64+
// InviteCodeCreateItem contains a newly created invite code and its plain text.
65+
type InviteCodeCreateItem struct {
66+
Code string `json:"code"`
67+
InviteCode *InviteCode `json:"inviteCode"`
68+
}
69+
70+
// InviteCodeCreateResult is returned when creating invite codes.
71+
type InviteCodeCreateResult struct {
72+
Items []InviteCodeCreateItem `json:"items"`
73+
}
74+
75+
// NormalizeInviteCode trims and normalizes an invite code for hashing.
76+
func NormalizeInviteCode(code string) string {
77+
var b strings.Builder
78+
b.Grow(len(code))
79+
for _, r := range code {
80+
if unicode.IsSpace(r) {
81+
continue
82+
}
83+
if isDashRune(r) {
84+
continue
85+
}
86+
b.WriteRune(r)
87+
}
88+
return strings.ToUpper(b.String())
89+
}
90+
91+
func isDashRune(r rune) bool {
92+
switch {
93+
case r == '-':
94+
return true
95+
case r >= 0x2010 && r <= 0x2015:
96+
return true
97+
case r == 0x2212, r == 0xFE58, r == 0xFE63, r == 0xFF0D:
98+
return true
99+
default:
100+
return false
101+
}
102+
}
103+
104+
// HashInviteCode returns a SHA-256 hex hash for the given invite code.
105+
func HashInviteCode(code string) string {
106+
normalized := NormalizeInviteCode(code)
107+
sum := sha256.Sum256([]byte(normalized))
108+
return hex.EncodeToString(sum[:])
109+
}
110+
111+
// InviteCodePrefix returns a short prefix for display.
112+
func InviteCodePrefix(code string) string {
113+
normalized := NormalizeInviteCode(code)
114+
if normalized == "" {
115+
return InviteCodeInvalidPrefix
116+
}
117+
if len(normalized) <= 8 {
118+
return normalized
119+
}
120+
return normalized[:8]
121+
}

0 commit comments

Comments
 (0)