7434e5195e
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>
137 lines
3.9 KiB
Go
137 lines
3.9 KiB
Go
package adminstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"github.com/baicai2026-baicai/goods/api/internal/apikey"
|
|
)
|
|
|
|
// APIKeyRow is an admin-facing view of an issued API key (never the secret).
|
|
type APIKeyRow struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
KeyPrefix string `json:"key_prefix"`
|
|
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"`
|
|
}
|
|
|
|
// APIKeyInput holds the fields accepted when issuing a key.
|
|
type APIKeyInput struct {
|
|
Name string `json:"name"`
|
|
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
|
|
// stored row. Only the SHA-256 hash and a short display prefix are persisted.
|
|
func (s *Store) CreateAPIKey(ctx context.Context, in APIKeyInput, createdBy string) (plaintext string, row APIKeyRow, err error) {
|
|
tier := in.Tier
|
|
if tier == "" {
|
|
tier = "free"
|
|
}
|
|
rate := in.RateLimitPerMin
|
|
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
|
|
}
|
|
|
|
key, hash, prefix, err := apikey.Generate()
|
|
if err != nil {
|
|
return "", row, err
|
|
}
|
|
|
|
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, 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, &row.QuotaTotal, &revoked, &createdByOut, &created)
|
|
if err != nil {
|
|
return "", row, err
|
|
}
|
|
row.CreatedBy = createdByOut
|
|
if created != nil {
|
|
row.CreatedAt = created.Format(time.RFC3339)
|
|
}
|
|
return key, row, nil
|
|
}
|
|
|
|
// 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, quota_total, revoked_at, created_by, created_at
|
|
FROM api_key
|
|
ORDER BY (revoked_at IS NULL) DESC, created_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []APIKeyRow{}
|
|
for rows.Next() {
|
|
var r APIKeyRow
|
|
var revoked, created *time.Time
|
|
if err := rows.Scan(&r.ID, &r.Name, &r.KeyPrefix, &r.OwnerEmail, &r.Tier,
|
|
&r.RateLimitPerMin, &r.QuotaTotal, &revoked, &r.CreatedBy, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
if revoked != nil {
|
|
v := revoked.Format(time.RFC3339)
|
|
r.RevokedAt = &v
|
|
}
|
|
if created != nil {
|
|
r.CreatedAt = created.Format(time.RFC3339)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RevokeAPIKey marks a key revoked. Revoking an already-revoked or missing key
|
|
// returns ErrNotFound.
|
|
func (s *Store) RevokeAPIKey(ctx context.Context, id string) error {
|
|
tag, err := s.pool.Exec(ctx,
|
|
"UPDATE api_key SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", id)
|
|
if err != nil {
|
|
if isInvalidUUID(err) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isInvalidUUID reports whether err is a Postgres invalid-UUID-text error,
|
|
// which happens when a non-UUID id is supplied.
|
|
func isInvalidUUID(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) {
|
|
return pgErr.Code == "22P02"
|
|
}
|
|
return false
|
|
}
|