179 lines
7.5 KiB
C#
179 lines
7.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 微信小程序码服务,与登录服务隔离,避免制码故障影响现有登录链路。
|
|
/// </summary>
|
|
public class WeChatMiniProgramCodeService : ITransient
|
|
{
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ILogger<WeChatMiniProgramCodeService> _logger;
|
|
private readonly WeChatOptions _options;
|
|
private readonly IEasyCachingProvider _cache;
|
|
|
|
private string AccessTokenCacheKey => $"wechat:mini-program:access-token:{_options.AppId}";
|
|
|
|
public WeChatMiniProgramCodeService(IHttpClientFactory httpClientFactory,
|
|
ILogger<WeChatMiniProgramCodeService> logger, IOptions<WeChatOptions> options,
|
|
IEasyCachingProvider cache)
|
|
{
|
|
_httpClientFactory = httpClientFactory;
|
|
_logger = logger;
|
|
_options = options.Value;
|
|
_cache = cache;
|
|
}
|
|
|
|
public async Task<byte[]> 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<byte[]> 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<WeChatApiError>(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<string> GetAccessTokenAsync(bool forceRefresh, CancellationToken cancellationToken)
|
|
{
|
|
if (!forceRefresh)
|
|
{
|
|
var cached = await _cache.GetAsync<string>(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<WeChatAccessTokenResponse>(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; }
|
|
}
|
|
}
|