feat(api): tiered cumulative quota + self-service registration
Anonymous callers get a free cumulative quota (1000 calls per IP); once exhausted they get 403 quota_exhausted and must register. Public users can self-register (email+password) to obtain a higher-quota API key, view usage, and regenerate the key. Quota counters live in Redis; the public API stays read-only except for the registration writes. - migration 0011: app_user table + api_key.quota_total + 'registered' tier - ratelimit: IncrTotal/TotalUsed/CopyTotal lifetime counters - middleware: enforce cumulative quota + X-Quota-* headers - store: RegisterUser/Authenticate/RegenerateKey (bcrypt) - handlers: POST /api/v1/register, /account, /account/regenerate - admin: quota_total column + registered tier - public: 'API 密钥' account page + API docs quota section Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -19,6 +19,7 @@ type APIKeyRow struct {
|
||||
OwnerEmail *string `json:"owner_email"`
|
||||
Tier string `json:"tier"`
|
||||
RateLimitPerMin int `json:"rate_limit_per_min"`
|
||||
QuotaTotal int64 `json:"quota_total"`
|
||||
RevokedAt *string `json:"revoked_at"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
@@ -30,6 +31,7 @@ type APIKeyInput struct {
|
||||
OwnerEmail string `json:"owner_email"`
|
||||
Tier string `json:"tier"`
|
||||
RateLimitPerMin int `json:"rate_limit_per_min"`
|
||||
QuotaTotal int64 `json:"quota_total"`
|
||||
}
|
||||
|
||||
// CreateAPIKey issues a new key, returning the one-time plaintext alongside the
|
||||
@@ -43,6 +45,10 @@ func (s *Store) CreateAPIKey(ctx context.Context, in APIKeyInput, createdBy stri
|
||||
if rate <= 0 {
|
||||
rate = 120
|
||||
}
|
||||
quota := in.QuotaTotal
|
||||
if quota < 0 {
|
||||
quota = 0
|
||||
}
|
||||
var owner *string
|
||||
if e := strings.TrimSpace(in.OwnerEmail); e != "" {
|
||||
owner = &e
|
||||
@@ -56,12 +62,12 @@ func (s *Store) CreateAPIKey(ctx context.Context, in APIKeyInput, createdBy stri
|
||||
var revoked, created *time.Time
|
||||
var createdByOut *string
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, key_prefix, owner_email, tier, rate_limit_per_min, revoked_at, created_by, created_at`,
|
||||
strings.TrimSpace(in.Name), prefix, hash, owner, tier, rate, createdBy,
|
||||
INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, quota_total, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, name, key_prefix, owner_email, tier, rate_limit_per_min, quota_total, revoked_at, created_by, created_at`,
|
||||
strings.TrimSpace(in.Name), prefix, hash, owner, tier, rate, quota, createdBy,
|
||||
).Scan(&row.ID, &row.Name, &row.KeyPrefix, &row.OwnerEmail, &row.Tier,
|
||||
&row.RateLimitPerMin, &revoked, &createdByOut, &created)
|
||||
&row.RateLimitPerMin, &row.QuotaTotal, &revoked, &createdByOut, &created)
|
||||
if err != nil {
|
||||
return "", row, err
|
||||
}
|
||||
@@ -75,7 +81,7 @@ RETURNING id, name, key_prefix, owner_email, tier, rate_limit_per_min, revoked_a
|
||||
// ListAPIKeys returns all keys (active first, newest first).
|
||||
func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKeyRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, key_prefix, owner_email, tier, rate_limit_per_min, revoked_at, created_by, created_at
|
||||
SELECT id, name, key_prefix, owner_email, tier, rate_limit_per_min, quota_total, revoked_at, created_by, created_at
|
||||
FROM api_key
|
||||
ORDER BY (revoked_at IS NULL) DESC, created_at DESC`)
|
||||
if err != nil {
|
||||
@@ -87,7 +93,7 @@ ORDER BY (revoked_at IS NULL) DESC, created_at DESC`)
|
||||
var r APIKeyRow
|
||||
var revoked, created *time.Time
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.KeyPrefix, &r.OwnerEmail, &r.Tier,
|
||||
&r.RateLimitPerMin, &revoked, &r.CreatedBy, &created); err != nil {
|
||||
&r.RateLimitPerMin, &r.QuotaTotal, &revoked, &r.CreatedBy, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if revoked != nil {
|
||||
|
||||
Reference in New Issue
Block a user