package wechat import ( "bytes" "context" "encoding/json" "fmt" "net/http" "sync" "time" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/httpclient" redispkg "bindbox-game/internal/pkg/redis" ) // AccessTokenRequest 获取 access_token 请求参数 type AccessTokenRequest struct { AppID string `json:"appid"` AppSecret string `json:"secret"` GrantType string `json:"grant_type"` } // AccessTokenResponse 获取 access_token 响应 type AccessTokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` ErrCode int `json:"errcode,omitempty"` ErrMsg string `json:"errmsg,omitempty"` } // TokenCache access_token 缓存结构 type TokenCache struct { Token string ExpiresAt time.Time mutex sync.RWMutex } // 全局 token 缓存 var globalTokenCache = &TokenCache{} // WechatConfig 微信配置 type WechatConfig struct { AppID string AppSecret string } // QRCodeRequest 获取小程序码请求参数 type QRCodeRequest struct { Path string `json:"path"` Width int `json:"width,omitempty"` AutoColor bool `json:"auto_color,omitempty"` LineColor *LineColor `json:"line_color,omitempty"` IsHyaline bool `json:"is_hyaline,omitempty"` EnvVersion string `json:"release,omitempty"` } type LineColor struct { R int `json:"r"` G int `json:"g"` B int `json:"b"` } // QRCodeResponse 获取小程序码响应 type QRCodeResponse struct { Buffer []byte `json:"-"` ErrCode int `json:"errcode,omitempty"` ErrMsg string `json:"errmsg,omitempty"` } // QRCodeError 微信API错误响应 type QRCodeError struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } func (e *QRCodeError) Error() string { return fmt.Sprintf("微信API错误: errcode=%d, errmsg=%s", e.ErrCode, e.ErrMsg) } // GetAccessToken 获取微信 access_token func GetAccessToken(ctx core.Context, config *WechatConfig) (string, error) { if config == nil { return "", fmt.Errorf("微信配置不能为空") } if config.AppID == "" { return "", fmt.Errorf("AppID 不能为空") } if config.AppSecret == "" { return "", fmt.Errorf("AppSecret 不能为空") } // 1. 先检查缓存是否有效 globalTokenCache.mutex.RLock() if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) { token := globalTokenCache.Token globalTokenCache.mutex.RUnlock() return token, nil } globalTokenCache.mutex.RUnlock() // 2. 缓存失效,需要获取新 token,使用写锁防止并发重复请求 globalTokenCache.mutex.Lock() defer globalTokenCache.mutex.Unlock() // 双重检查:可能在等待锁期间已被其他协程更新 if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) { return globalTokenCache.Token, nil } // 3. 调用微信 API 获取新 token (使用 stable_token 接口) url := "https://api.weixin.qq.com/cgi-bin/stable_token" requestBody := map[string]any{ "grant_type": "client_credential", "appid": config.AppID, "secret": config.AppSecret, "force_refresh": false, } client := httpclient.GetHttpClientWithContext(ctx.RequestContext()) resp, err := client.R(). SetBody(requestBody). Post(url) if err != nil { return "", fmt.Errorf("获取stable_access_token失败: %v", err) } if resp.StatusCode() != http.StatusOK { return "", fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode()) } var tokenResp AccessTokenResponse if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil { return "", fmt.Errorf("解析access_token响应失败: %v", err) } if tokenResp.ErrCode != 0 { return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg) } if tokenResp.AccessToken == "" { return "", fmt.Errorf("获取到的access_token为空") } // 4. 更新缓存(提前5分钟过期以留出刷新余地) expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) globalTokenCache.Token = tokenResp.AccessToken globalTokenCache.ExpiresAt = expiresAt return tokenResp.AccessToken, nil } // GetAccessTokenWithContext 获取微信 access_token(使用 context.Context) // 用于后台任务等无 core.Context 的场景 // 优先使用 Redis 缓存实现跨实例共享,Redis 不可用时降级到内存缓存 func GetAccessTokenWithContext(ctx context.Context, config *WechatConfig) (string, error) { if config == nil { return "", fmt.Errorf("微信配置不能为空") } if config.AppID == "" { return "", fmt.Errorf("AppID 不能为空") } if config.AppSecret == "" { return "", fmt.Errorf("AppSecret 不能为空") } // 1. 尝试从 Redis 获取 token redisKey := fmt.Sprintf("wechat:access_token:%s", config.AppID) if token, err := getTokenFromRedis(ctx, redisKey); err == nil && token != "" { return token, nil } // 2. Redis 中没有,使用分布式锁获取新 token lockKey := fmt.Sprintf("lock:wechat:access_token:%s", config.AppID) locked, err := acquireDistributedLock(ctx, lockKey, 10*time.Second) if err != nil || !locked { // 如果获取锁失败,等待一小段时间后重试读取(可能其他实例正在获取) time.Sleep(100 * time.Millisecond) if token, err := getTokenFromRedis(ctx, redisKey); err == nil && token != "" { return token, nil } // 如果还是没有,降级到内存缓存逻辑 return getTokenFromMemoryOrAPI(ctx, config) } defer releaseDistributedLock(ctx, lockKey) // 3. 双重检查:获取锁后再次检查 Redis if token, err := getTokenFromRedis(ctx, redisKey); err == nil && token != "" { return token, nil } // 4. 调用微信 API 获取新 token (使用 stable_token 接口) url := "https://api.weixin.qq.com/cgi-bin/stable_token" requestBody := map[string]any{ "grant_type": "client_credential", "appid": config.AppID, "secret": config.AppSecret, "force_refresh": false, } client := httpclient.GetHttpClient() resp, err := client.R(). SetBody(requestBody). Post(url) if err != nil { return "", fmt.Errorf("获取stable_access_token失败: %v", err) } if resp.StatusCode() != http.StatusOK { return "", fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode()) } var tokenResp AccessTokenResponse if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil { return "", fmt.Errorf("解析access_token响应失败: %v", err) } if tokenResp.ErrCode != 0 { return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg) } if tokenResp.AccessToken == "" { return "", fmt.Errorf("获取到的access_token为空") } // 5. 存储到 Redis (提前5分钟过期以留出刷新余地) expiresIn := tokenResp.ExpiresIn - 300 if expiresIn < 60 { expiresIn = 60 // 最少缓存1分钟 } saveTokenToRedis(ctx, redisKey, tokenResp.AccessToken, expiresIn) // 6. 同时更新内存缓存作为降级备份 globalTokenCache.mutex.Lock() globalTokenCache.Token = tokenResp.AccessToken globalTokenCache.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) globalTokenCache.mutex.Unlock() return tokenResp.AccessToken, nil } // GetQRCodeWithConfig 通过微信配置获取小程序码(自动获取access_token) // config: 微信配置(AppID 和 AppSecret) // request: 请求参数 // 返回: 成功时返回图片二进制数据,失败时返回错误 func GetQRCodeWithConfig(ctx core.Context, config *WechatConfig, request *QRCodeRequest) (*QRCodeResponse, error) { // 自动获取 access_token accessToken, err := GetAccessToken(ctx, config) if err != nil { return nil, fmt.Errorf("获取access_token失败: %v", err) } return GetQRCode(ctx, accessToken, request) } // GetQRCode 获取小程序码 // accessToken: 接口调用凭证 // request: 请求参数 // 返回: 成功时返回图片二进制数据,失败时返回错误 func GetQRCode(ctx core.Context, accessToken string, request *QRCodeRequest) (*QRCodeResponse, error) { if accessToken == "" { return nil, fmt.Errorf("access_token 不能为空") } if request == nil { return nil, fmt.Errorf("请求参数不能为空") } if request.Path == "" { return nil, fmt.Errorf("path 参数不能为空") } if len(request.Path) > 1024 { return nil, fmt.Errorf("path 参数长度不能超过1024个字符") } // 设置默认值 if request.Width == 0 { request.Width = 430 } if request.Width < 280 { request.Width = 280 } if request.Width > 1280 { request.Width = 1280 } url := fmt.Sprintf("https://api.weixin.qq.com/wxa/getwxacode?access_token=%s", accessToken) requestBody, err := json.Marshal(request) if err != nil { return nil, fmt.Errorf("序列化请求参数失败: %v", err) } client := httpclient.GetHttpClientWithContext(ctx.RequestContext()) resp, err := client.R(). SetHeader("Content-Type", "application/json"). SetBody(requestBody). Post(url) if err != nil { return nil, fmt.Errorf("发送HTTP请求失败: %v", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode()) } responseBody := resp.Body() if isJSONResponse(responseBody) { var qrError QRCodeError if err := json.Unmarshal(responseBody, &qrError); err != nil { return nil, fmt.Errorf("解析错误响应失败: %v", err) } return nil, &qrError } return &QRCodeResponse{ Buffer: responseBody, }, nil } func isJSONResponse(data []byte) bool { if len(data) == 0 { return false } trimmed := bytes.TrimSpace(data) return len(trimmed) > 0 && trimmed[0] == '{' } // GenerateQRCode 最简化的小程序码生成接口 func GenerateQRCode(ctx core.Context, appID, appSecret, path string) ([]byte, error) { config := &WechatConfig{ AppID: appID, AppSecret: appSecret, } request := &QRCodeRequest{ Path: path, } response, err := GetQRCodeWithConfig(ctx, config, request) if err != nil { return nil, err } return response.Buffer, nil } // ========== Redis 缓存辅助函数 ========== // getTokenFromRedis 从 Redis 获取 access_token func getTokenFromRedis(ctx context.Context, key string) (string, error) { client := redispkg.GetClient() if client == nil { return "", fmt.Errorf("Redis client not available") } token, err := client.Get(ctx, key).Result() if err != nil { return "", err } return token, nil } // saveTokenToRedis 保存 access_token 到 Redis func saveTokenToRedis(ctx context.Context, key, token string, expiresIn int) { client := redispkg.GetClient() if client == nil { return // Redis 不可用,静默失败 } _ = client.Set(ctx, key, token, time.Duration(expiresIn)*time.Second).Err() } // acquireDistributedLock 获取分布式锁 func acquireDistributedLock(ctx context.Context, lockKey string, ttl time.Duration) (bool, error) { client := redispkg.GetClient() if client == nil { return false, fmt.Errorf("Redis client not available") } return client.SetNX(ctx, lockKey, "1", ttl).Result() } // releaseDistributedLock 释放分布式锁 func releaseDistributedLock(ctx context.Context, lockKey string) { client := redispkg.GetClient() if client == nil { return } _ = client.Del(ctx, lockKey).Err() } // getTokenFromMemoryOrAPI 降级方案:从内存缓存获取或调用API func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string, error) { // 1. 先检查内存缓存 globalTokenCache.mutex.RLock() if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) { token := globalTokenCache.Token globalTokenCache.mutex.RUnlock() return token, nil } globalTokenCache.mutex.RUnlock() // 2. 内存缓存也失效,使用写锁防止并发 globalTokenCache.mutex.Lock() defer globalTokenCache.mutex.Unlock() // 双重检查 if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) { return globalTokenCache.Token, nil } // 3. 调用微信 API (使用 stable_token 接口) url := "https://api.weixin.qq.com/cgi-bin/stable_token" requestBody := map[string]any{ "grant_type": "client_credential", "appid": config.AppID, "secret": config.AppSecret, "force_refresh": false, } client := httpclient.GetHttpClient() resp, err := client.R(). SetBody(requestBody). Post(url) if err != nil { return "", fmt.Errorf("获取stable_access_token失败: %v", err) } if resp.StatusCode() != http.StatusOK { return "", fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode()) } var tokenResp AccessTokenResponse if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil { return "", fmt.Errorf("解析access_token响应失败: %v", err) } if tokenResp.ErrCode != 0 { return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg) } if tokenResp.AccessToken == "" { return "", fmt.Errorf("获取到的access_token为空") } // 4. 更新内存缓存 expiresIn := tokenResp.ExpiresIn - 300 if expiresIn < 60 { expiresIn = 60 } globalTokenCache.Token = tokenResp.AccessToken globalTokenCache.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) return tokenResp.AccessToken, nil }