package wechat import ( "context" "encoding/json" "errors" "net/http" "net/url" "time" "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.uber.org/zap" ) // AppID is part of the key; environments can use different key prefixes or DBs. func (c *Client) tokenKey() string { return c.KeyPrefix + "wechat:{" + c.AppID + "}:access_token" } var compareDelete = redis.NewScript(`if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end`) // AccessToken shares cached tokens and a refresh lock across processes. // Redis failures never fall back to the old plaintext file or repeated refreshes. func (c *Client) AccessToken() string { c.mu.Lock() defer c.mu.Unlock() if c.Redis == nil { zap.L().Error("微信 token 缓存未初始化") return "" } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() key := c.tokenKey() lockKey, owner := key+":lock", uuid.NewString() for { token, err := c.Redis.Get(ctx, key).Result() if err == nil && token != "" { return token } if err != nil && !errors.Is(err, redis.Nil) { zap.L().Error("读取微信 token 缓存失败", zap.Error(err)) return "" } locked, err := c.Redis.SetNX(ctx, lockKey, owner, 30*time.Second).Result() if err != nil { zap.L().Error("获取微信 token 刷新锁失败", zap.Error(err)) return "" } if locked { defer func() { unlockCtx, done := context.WithTimeout(context.Background(), 2*time.Second) defer done() if err := compareDelete.Run(unlockCtx, c.Redis, []string{lockKey}, owner).Err(); err != nil { zap.L().Warn("释放微信 token 刷新锁失败", zap.Error(err)) } }() // Another process may have filled the cache between our GET and SET NX. token, err = c.Redis.Get(ctx, key).Result() if err == nil && token != "" { return token } if err != nil && !errors.Is(err, redis.Nil) { zap.L().Error("读取微信 token 缓存失败", zap.Error(err)) return "" } return c.fetchToken(ctx, key) } timer := time.NewTimer(100 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() zap.L().Warn("等待微信 token 刷新超时") return "" case <-timer.C: } } } func (c *Client) fetchToken(ctx context.Context, key string) string { endpoint := "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + url.QueryEscape(c.AppID) + "&secret=" + url.QueryEscape(c.Secret) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return "" } resp, err := c.http.Do(req) if err != nil { zap.L().Error("微信 token 请求失败") return "" } // Do not log the URL containing AppSecret. defer resp.Body.Close() var data struct { AccessToken string `json:"access_token"` ExpiresIn int64 `json:"expires_in"` Errcode int `json:"errcode"` } if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || resp.StatusCode != http.StatusOK || data.Errcode != 0 || data.AccessToken == "" || data.ExpiresIn <= 0 { zap.L().Error("微信 token 返回无效", zap.Int("http_status", resp.StatusCode), zap.Int("errcode", data.Errcode)) return "" } ttl := time.Duration(data.ExpiresIn) * time.Second // Refresh early without making short-lived tokens immediately expire. margin := min(ttl/10, 5*time.Minute) ttl -= margin if err := c.Redis.Set(ctx, key, data.AccessToken, ttl).Err(); err != nil { zap.L().Error("写入微信 token 缓存失败", zap.Error(err)) return "" } return data.AccessToken } // Invalidate only the token rejected by WeChat, preserving a concurrent refresh. func (c *Client) clearTokenCache(token string) { if c.Redis == nil || token == "" { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := compareDelete.Run(ctx, c.Redis, []string{c.tokenKey()}, token).Err(); err != nil { zap.L().Warn("清理失效微信 token 失败", zap.Error(err)) } }