using System.Net.Http.Json;
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));
return await GenerateAsync(scene, page, width, false, timeout.Token);
}
private async Task GenerateAsync(string scene, string page, int width,
bool forceRefreshToken, CancellationToken cancellationToken)
{
var accessToken = await GetAccessTokenAsync(forceRefreshToken, cancellationToken);
var client = _httpClientFactory.CreateClient();
using var response = await client.PostAsJsonAsync(
$"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={Uri.EscapeDataString(accessToken)}",
new
{
scene,
page,
width,
check_path = true,
env_version = NormalizeEnvVersion(_options.MiniProgramEnvVersion)
}, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogError("WeChat mini program code HTTP error: {StatusCode}, Body: {ErrorBody}",
response.StatusCode, 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}", 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, 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}", 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?.ToLowerInvariant() switch
{
"develop" => "develop",
"trial" => "trial",
_ => "release"
};
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; }
}
}