using System.Text; using EasyCaching.Core; using Furion.DependencyInjection; using Furion.FriendlyException; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using YD_Event.Core.Options; namespace YD_Event.Core.Infrastructure.WeChat; /// /// 微信小程序码服务,与登录服务隔离,避免制码故障影响现有登录链路。 /// public class WeChatMiniProgramCodeService : ITransient { private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly WeChatOptions _options; private readonly IEasyCachingProvider _cache; private string AccessTokenCacheKey => $"wechat:mini-program:access-token:{_options.AppId}"; public WeChatMiniProgramCodeService(IHttpClientFactory httpClientFactory, ILogger logger, IOptions options, IEasyCachingProvider cache) { _httpClientFactory = httpClientFactory; _logger = logger; _options = options.Value; _cache = cache; } public async Task GenerateAsync(string scene, string page, int width = 430) { if (string.IsNullOrWhiteSpace(scene) || Encoding.UTF8.GetByteCount(scene) > 32) throw Oops.Bah("小程序码参数无效"); if (string.IsNullOrWhiteSpace(page)) throw Oops.Bah("小程序页面路径不能为空"); if (string.IsNullOrWhiteSpace(_options.AppId) || string.IsNullOrWhiteSpace(_options.Secret)) throw Oops.Bah("微信小程序配置不完整"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); try { return await GenerateAsync(scene, page, width, false, timeout.Token); } catch (OperationCanceledException) when (timeout.IsCancellationRequested) { _logger.LogError("WeChat mini program code request timed out after 15 seconds"); throw Oops.Bah("微信小程序码服务请求超时,请稍后重试"); } catch (HttpRequestException exception) { _logger.LogError("WeChat mini program code network error: {ErrorType}, {ErrorMessage}", exception.GetType().Name, exception.Message); throw Oops.Bah("微信小程序码服务网络异常,请稍后重试"); } } private async Task GenerateAsync(string scene, string page, int width, bool forceRefreshToken, CancellationToken cancellationToken) { var accessToken = await GetAccessTokenAsync(forceRefreshToken, cancellationToken); var client = _httpClientFactory.CreateClient(); var requestBody = JsonConvert.SerializeObject(new { scene, page, width, check_path = true, env_version = NormalizeEnvVersion(_options.MiniProgramEnvVersion) }); using var requestContent = new StringContent(requestBody, Encoding.UTF8, "application/json"); using var response = await client.PostAsync( $"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={Uri.EscapeDataString(accessToken)}", requestContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogError( "WeChat mini program code HTTP error: {StatusCode}, Page: {Page}, EnvVersion: {EnvVersion}, Body: {ErrorBody}", response.StatusCode, page, NormalizeEnvVersion(_options.MiniProgramEnvVersion), LimitLogContent(errorBody)); throw Oops.Bah("微信小程序码服务暂不可用,请稍后重试"); } var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); var mediaType = response.Content.Headers.ContentType?.MediaType; if (mediaType?.StartsWith("image/", StringComparison.OrdinalIgnoreCase) == true || IsJpeg(bytes)) return bytes; var errorJson = Encoding.UTF8.GetString(bytes); WeChatApiError? error = null; try { error = JsonConvert.DeserializeObject(errorJson); } catch (JsonException) { // 非微信标准错误体,统一按服务异常处理,原文只写入服务端日志。 } if (!forceRefreshToken && error?.ErrCode is 40001 or 40014 or 42001) { await _cache.RemoveAsync(AccessTokenCacheKey); return await GenerateAsync(scene, page, width, true, cancellationToken); } _logger.LogError("WeChat mini program code error: {Error}", LimitLogContent(errorJson)); throw Oops.Bah("微信小程序码生成失败,请稍后重试"); } private async Task GetAccessTokenAsync(bool forceRefresh, CancellationToken cancellationToken) { if (!forceRefresh) { var cached = await _cache.GetAsync(AccessTokenCacheKey); if (cached.HasValue && !string.IsNullOrWhiteSpace(cached.Value)) return cached.Value; } var client = _httpClientFactory.CreateClient(); var url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential" + $"&appid={Uri.EscapeDataString(_options.AppId)}&secret={Uri.EscapeDataString(_options.Secret)}"; using var response = await client.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogError("WeChat access token HTTP error: {StatusCode}, Body: {ErrorBody}", response.StatusCode, LimitLogContent(errorBody)); throw Oops.Bah("微信服务暂不可用,请稍后重试"); } var json = await response.Content.ReadAsStringAsync(cancellationToken); var token = JsonConvert.DeserializeObject(json); if (token == null || string.IsNullOrWhiteSpace(token.AccessToken)) { _logger.LogError("WeChat access token error: {Error}", LimitLogContent(json)); throw Oops.Bah("微信访问凭证获取失败,请检查小程序配置"); } await _cache.SetAsync(AccessTokenCacheKey, token.AccessToken, TimeSpan.FromSeconds(Math.Max(60, token.ExpiresIn - 300))); return token.AccessToken; } private static bool IsJpeg(byte[] bytes) => bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; private static string NormalizeEnvVersion(string? envVersion) => envVersion?.Trim().ToLowerInvariant() switch { "develop" => "develop", "trial" => "trial", null or "" or "release" => "release", _ => throw Oops.Bah("微信小程序码版本配置无效,仅支持 develop、trial 或 release") }; private static string LimitLogContent(string content) => content.Length <= 4096 ? content : $"{content[..4096]}...(truncated)"; private sealed class WeChatAccessTokenResponse : WeChatApiError { [JsonProperty("access_token")] public string? AccessToken { get; set; } [JsonProperty("expires_in")] public int ExpiresIn { get; set; } } private class WeChatApiError { [JsonProperty("errcode")] public int ErrCode { get; set; } [JsonProperty("errmsg")] public string? ErrMsg { get; set; } } }