字
字节笔记本
2026年7月20日
用 Go + Redis 实现用户级别的 API 调用次数限制
API中转
¥120
给 OpenAI API 加上用户级别的调用次数限制,是一个常见的管控需求。本文用 Go + Gin + GORM + Redis 实现一个基于 OpenID 的每日调用配额系统:Redis 做实时计数(高性能),GORM 做持久化记录(防丢数),两个层级配合使用。
整体思路
- 用户通过 JWT 识别身份(从 token 中取 OpenID)
- 每次调用 OpenAI API 前,先检查该用户今天的调用次数
- Redis 中存当日计数(key 带日期,当天自动过期)
- GORM 中存历史记录(APICallRecord 表,按日期 + 用户去重)
- 缓存未命中时回查数据库,数据库也没有则从零开始
text
请求 → JWT 中间件提取 OpenID
→ Service 查 Redis 计数
→ 命中:直接判断
→ 未命中:查数据库
→ 有记录:加载到缓存
→ 无记录:从 0 开始
→ 未超限:计数 +1,写缓存 + 写数据库
→ 已超限:返回 429
→ 调用 OpenAI API数据模型
User 表(已有)
go
type User struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Username string `gorm:"size:100;not null"`
OpenID string `gorm:"uniqueIndex;size:100;not null"`
}APICallRecord 表(新增)
go
type APICallRecord struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
OpenID string `gorm:"size:100;not null;index:idx_openid_date,priority:1"`
CallDate time.Time `gorm:"index:idx_openid_date,priority:2;not null"`
CallCount int `gorm:"default:0;not null"`
}
func (APICallRecord) TableName() string { return "api_call_records" }联合索引 idx_openid_date 保证按用户 + 日期查询的高效,CallCount 记录当天累计调用次数。
Redis 缓存设计
缓存 key 的格式:api:call:count:{openID}:{YYYY-MM-DD}
过期时间设为当天剩余秒数,到零点自动清理,不用写定时任务。
go
func getCacheKey(openID string) string {
today := time.Now().UTC().Format("2006-01-02")
return fmt.Sprintf("api:call:count:%s:%s", openID, today)
}
func todayRemainingDuration() time.Duration {
now := time.Now().UTC()
tomorrow := now.Truncate(24 * time.Hour).Add(24 * time.Hour)
return tomorrow.Sub(now)
}Service 层
go
package service
import (
"errors"
"fmt"
"time"
"gorm.io/gorm"
)
const DailyLimit = 10 // 每日调用上限
type callInfo struct {
Count int `json:"count"`
Date time.Time `json:"date"`
}
// ApiService API 调用配额服务
type ApiService struct {
db *gorm.DB
cache Cache
}
func NewAPIService(db *gorm.DB, cache Cache) *ApiService {
return &ApiService{db: db, cache: cache}
}
// CheckAndIncrementAPICall 检查配额并递增调用次数
func (s *ApiService) CheckAndIncrementAPICall(openID string) error {
// 1. 验证用户存在
var user User
if err := s.db.Where("open_id = ?", openID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("用户不存在")
}
return fmt.Errorf("数据库查询失败: %w", err)
}
// 2. 获取当前计数
info, err := s.getCallInfo(openID)
if err != nil {
return err
}
// 3. 检查配额
if info.Count >= DailyLimit {
return errors.New("今日调用次数已达上限")
}
// 4. 递增计数
info.Count++
// 5. 写缓存(带当天剩余过期时间)
cacheKey := getCacheKey(openID)
if err := s.cache.Set(cacheKey, info, todayRemainingDuration()); err != nil {
// 缓存写入失败不阻断请求,只记日志
log.Printf("warn: cache set failed: %v", err)
}
// 6. 写数据库(Upsert)
today := time.Now().UTC().Truncate(24 * time.Hour)
record := APICallRecord{
OpenID: openID,
CallDate: today,
CallCount: info.Count,
}
err = s.db.Where("open_id = ? AND call_date = ?", openID, today).
Assign(APICallRecord{CallCount: info.Count}).
FirstOrCreate(&record).Error
if err != nil {
return fmt.Errorf("更新调用记录失败: %w", err)
}
return nil
}
// GetRemainingCalls 获取今日剩余调用次数
func (s *ApiService) GetRemainingCalls(openID string) (int, error) {
info, err := s.getCallInfo(openID)
if err != nil {
return 0, err
}
return DailyLimit - info.Count, nil
}
// ResetTodayCalls 清空用户今日调用次数(管理员操作)
func (s *ApiService) ResetTodayCalls(openID string) error {
today := time.Now().UTC().Truncate(24 * time.Hour)
result := s.db.Where("open_id = ? AND call_date = ?", openID, today).
Delete(&APICallRecord{})
if result.Error != nil {
return result.Error
}
// 同时清缓存
cacheKey := getCacheKey(openID)
_ = s.cache.Delete(cacheKey)
return nil
}
// getCallInfo 获取调用计数(缓存优先,回查数据库)
func (s *ApiService) getCallInfo(openID string) (*callInfo, error) {
cacheKey := getCacheKey(openID)
var info callInfo
// 先查缓存
err := s.cache.Get(cacheKey, &info)
if err == nil {
return &info, nil
}
// 缓存未命中,查数据库
today := time.Now().UTC().Truncate(24 * time.Hour)
var record APICallRecord
err = s.db.Where("open_id = ? AND call_date = ?", openID, today).First(&record).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 今天没有记录,从零开始
return &callInfo{Count: 0, Date: today}, nil
}
return nil, fmt.Errorf("查询调用记录失败: %w", err)
}
// 从数据库加载到缓存
info = callInfo{Count: record.CallCount, Date: record.CallDate}
_ = s.cache.Set(cacheKey, info, todayRemainingDuration())
return &info, nil
}Controller 层
go
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
)
type OpenAIHandler struct {
apiSvc *service.ApiService
chatSvc *service.ChatService
}
func NewOpenAIHandler(apiSvc *service.ApiService, chatSvc *service.ChatService) *OpenAIHandler {
return &OpenAIHandler{apiSvc: apiSvc, chatSvc: chatSvc}
}
// CallOpenAI 调用 OpenAI API(含配额检查)
func (h *OpenAIHandler) CallOpenAI(c *gin.Context) {
openID, exists := c.Get("openid")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
openIDStr := openID.(string)
// 配额检查
if err := h.apiSvc.CheckAndIncrementAPICall(openIDStr); err != nil {
if err.Error() == "今日调用次数已达上限" {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": err.Error(),
"limit": DailyLimit,
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 配额通过,执行实际的 OpenAI 调用(可以是流式或非流式)
h.chatSvc.StreamChat(c)
}
// GetRemainingCalls 获取今日剩余调用次数
func (h *OpenAIHandler) GetRemainingCalls(c *gin.Context) {
openID, _ := c.Get("openid")
openIDStr := openID.(string)
remaining, err := h.apiSvc.GetRemainingCalls(openIDStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"remaining_calls": remaining,
"daily_limit": DailyLimit,
})
}
// ResetTodayCalls 清空用户今日调用次数(管理员接口)
func (h *OpenAIHandler) ResetTodayCalls(c *gin.Context) {
openID := c.Param("openid")
if openID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 openid 参数"})
return
}
if err := h.apiSvc.ResetTodayCalls(openID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已清空今日调用次数"})
}路由配置
go
func SetupRoutes(r *gin.Engine, handler *controller.OpenAIHandler) {
// 公开路由
r.POST("/api/auth/login", authHandler.Login)
// 需要鉴权的用户路由
user := r.Group("/user")
user.Use(middleware.JWTAuth())
{
user.POST("/openai/chat", handler.CallOpenAI)
user.GET("/openai/remaining", handler.GetRemainingCalls)
}
// 管理员路由(需要额外鉴权)
admin := r.Group("/admin")
admin.Use(middleware.JWTAuth(), middleware.AdminOnly())
{
admin.POST("/openai/reset/:openid", handler.ResetTodayCalls)
}
}几个设计决策
- Redis + GORM 双写,缓存优先:Redis 承担高频读写,GORM 做持久化兜底。Redis 挂了不影响功能,只是性能下降到数据库级别。
- 缓存 key 带日期且自动过期:不用写定时任务清理,第二天的 key 自然失效。
todayRemainingDuration()计算当天剩余秒数作为 TTL。 - DailyLimit 定义为常量:方便全局调整,也方便后续扩展为按用户等级/套餐动态获取。
- Upsert 用
Assign + FirstOrCreate:一条语句完成"有则更新 CallCount,无则创建",避免先查后写的竞态。 - 清空操作同时删缓存和数据库记录:管理员的 Reset 接口需要确保两个存储层一致。
这套方案可以直接用在 AI 对话、翻译、图片生成等按次计费的场景中,把 DailyLimit 换成从数据库读取的用户套餐配额即可。
分享: