增加初始化类文件
xiyunfei authored at 2026-02-27 00:31:51
13.49 KiB
NewLife.WeChat
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using NewLife;
using NewLife.Caching;
using NewLife.Log;
using NewLife.Serialization;
using NewLife.WeChat.Entities;
using NewLife.WeChat.Models;

namespace NewLife.WeChat.Services;

/// <summary>微信服务。提供微信API调用封装</summary>
public class WeChatService
{
    #region 属性
    /// <summary>微信配置</summary>
    public 微信配置 Config { get; set; }

    /// <summary>HTTP客户端</summary>
    private readonly HttpClient _client;

    /// <summary>缓存</summary>
    private readonly ICache _cache;

    /// <summary>日志</summary>
    public ILog Log { get; set; }
    #endregion

    #region 构造
    /// <summary>实例化微信服务</summary>
    public WeChatService()
    {
        _client = new HttpClient();
        _cache = Cache.Default;
        Log = XTrace.Log;
    }

    /// <summary>实例化微信服务</summary>
    /// <param name="config">微信配置</param>
    public WeChatService(微信配置 config) : this()
    {
        Config = config;
    }
    #endregion

    #region AccessToken 管理
    /// <summary>获取访问令牌(自动缓存)</summary>
    /// <param name="appid">AppId,为空时使用Config的AppId</param>
    /// <param name="forceRefresh">是否强制刷新</param>
    /// <returns></returns>
    public async Task<String> GetAccessTokenAsync(String appid = null, Boolean forceRefresh = false)
    {
        appid ??= Config?.AppId;
        if (appid.IsNullOrEmpty()) throw new ArgumentNullException(nameof(appid), "AppId不能为空");

        var key = GetTokenCacheKey(appid);

        // 检查缓存
        if (!forceRefresh)
        {
            var cached = _cache.Get<String>(key);
            if (!cached.IsNullOrEmpty())
            {
                Log?.Debug($"从缓存获取AccessToken: {appid}");
                return cached;
            }
        }

        // 刷新 Token
        return await RefreshAccessTokenAsync(appid);
    }

    /// <summary>刷新访问令牌</summary>
    /// <param name="appid">AppId</param>
    /// <returns></returns>
    public async Task<String> RefreshAccessTokenAsync(String appid)
    {
        var config = Config?.AppId == appid ? Config : 微信配置.FindByAppId(appid);
        if (config == null) throw new InvalidOperationException($"未找到AppId为 {appid} 的配置");

        Log?.Info($"刷新AccessToken: {appid}");

        var url = $"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={config.AppId}&secret={config.AppSecret}";
        var result = await GetAsync<AccessTokenResponse>(url);

        if (!result.IsSuccess)
            throw new Exception($"获取AccessToken失败: {result.ErrCode} - {result.ErrMsg}");

        // 缓存 Token(提前5分钟过期)
        var key = GetTokenCacheKey(appid);
        _cache.Set(key, result.Access_Token, result.Expires_In - 300);

        Log?.Info($"AccessToken刷新成功: {appid},有效期: {result.Expires_In}秒");

        return result.Access_Token;
    }

    /// <summary>获取Token缓存键</summary>
    /// <param name="appid">AppId</param>
    /// <returns></returns>
    private String GetTokenCacheKey(String appid) => $"WeChat:AccessToken:{appid}";
    #endregion

    #region 用户信息获取
    /// <summary>通过授权码获取用户OpenId和UnionId</summary>
    /// <param name="appid">AppId</param>
    /// <param name="code">授权码</param>
    /// <returns></returns>
    public async Task<WeChatUserInfo> GetUserInfoByCodeAsync(String appid, String code)
    {
        if (code.IsNullOrEmpty()) throw new ArgumentNullException(nameof(code));

        var config = Config?.AppId == appid ? Config : 微信配置.FindByAppId(appid);
        if (config == null) throw new InvalidOperationException($"未找到AppId为 {appid} 的配置");

        Log?.Info($"通过Code获取用户信息: {appid}, code: {code}");

        // 1. 通过 code 获取 access_token 和 openid
        var tokenUrl = $"https://api.weixin.qq.com/sns/oauth2/access_token?appid={config.AppId}&secret={config.AppSecret}&code={code}&grant_type=authorization_code";
        var tokenResult = await GetAsync<OAuthTokenResponse>(tokenUrl);

        if (!tokenResult.IsSuccess)
            throw new Exception($"获取OAuthToken失败: {tokenResult.ErrCode} - {tokenResult.ErrMsg}");

        // 2. 获取用户信息
        var userUrl = $"https://api.weixin.qq.com/sns/userinfo?access_token={tokenResult.Access_Token}&openid={tokenResult.OpenId}&lang=zh_CN";
        var userInfo = await GetAsync<WeChatUserInfo>(userUrl);

        if (!userInfo.IsSuccess)
            throw new Exception($"获取用户信息失败: {userInfo.ErrCode} - {userInfo.ErrMsg}");

        // 3. 保存到数据库
        微信用户.Sync(appid, userInfo.OpenId, userInfo.UnionId);

        Log?.Info($"获取用户信息成功: OpenId={userInfo.OpenId}, UnionId={userInfo.UnionId}");

        return userInfo;
    }

    /// <summary>获取用户详细信息(需要access_token)</summary>
    /// <param name="appid">AppId</param>
    /// <param name="openid">OpenId</param>
    /// <returns></returns>
    public async Task<WeChatUserInfo> GetUserDetailAsync(String appid, String openid)
    {
        var token = await GetAccessTokenAsync(appid);
        var url = $"https://api.weixin.qq.com/cgi-bin/user/info?access_token={token}&openid={openid}&lang=zh_CN";
        var userInfo = await GetAsync<WeChatUserInfo>(url);

        if (!userInfo.IsSuccess)
            throw new Exception($"获取用户详细信息失败: {userInfo.ErrCode} - {userInfo.ErrMsg}");

        // 保存到数据库
        微信用户.Sync(appid, userInfo.OpenId, userInfo.UnionId);

        return userInfo;
    }
    #endregion

    #region UnionId 关联查询
    /// <summary>根据UnionId查询用户在指定应用下的OpenId</summary>
    /// <param name="unionid">UnionId</param>
    /// <param name="targetAppId">目标应用AppId</param>
    /// <returns></returns>
    public String GetOpenIdByUnionId(String unionid, String targetAppId)
    {
        if (unionid.IsNullOrEmpty() || targetAppId.IsNullOrEmpty()) return null;

        var users = 微信用户.FindAllByUnionId(unionid);
        var targetUser = users.FirstOrDefault(u => u.AppId == targetAppId);

        return targetUser?.OpenId;
    }

    /// <summary>获取UnionId关联的所有OpenId</summary>
    /// <param name="unionid">UnionId</param>
    /// <returns></returns>
    public IList<微信用户> GetAllOpenIdsByUnionId(String unionid)
    {
        if (unionid.IsNullOrEmpty()) return new List<微信用户>();

        return 微信用户.FindAllByUnionId(unionid);
    }
    #endregion

    #region 模板消息发送
    /// <summary>发送公众号模板消息</summary>
    /// <param name="appid">AppId</param>
    /// <param name="openid">用户OpenId</param>
    /// <param name="templateid">模板ID</param>
    /// <param name="data">消息数据</param>
    /// <param name="url">跳转链接</param>
    /// <returns></returns>
    public async Task<Boolean> SendTemplateMessageAsync(String appid, String openid, String templateid, Object data, String url = null)
    {
        if (openid.IsNullOrEmpty()) throw new ArgumentNullException(nameof(openid));
        if (templateid.IsNullOrEmpty()) throw new ArgumentNullException(nameof(templateid));

        // 1. 查询模板配置
        var template = 微信模板消息配置.FindByTemplateId(appid, templateid);
        if (template == null)
        {
            Log?.Warn($"模板 {templateid} 不存在");
            return false;
        }

        if (!template.IsEnabled)
        {
            Log?.Warn($"模板 {templateid} 未启用");
            return false;
        }

        // 2. 获取 AccessToken
        var token = await GetAccessTokenAsync(appid);

        // 3. 构造请求
        var request = new TemplateMessageRequest
        {
            ToUser = openid,
            Template_Id = templateid,
            Url = url,
            Data = data
        };

        // 4. 发送请求
        var apiUrl = $"https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={token}";
        var result = await PostAsync<TemplateMessageResponse>(apiUrl, request);

        if (!result.IsSuccess)
        {
            Log?.Error($"发送模板消息失败: {result.ErrCode} - {result.ErrMsg}");
            return false;
        }

        Log?.Info($"模板消息发送成功: MsgId={result.MsgId}");
        return true;
    }

    /// <summary>发送小程序订阅消息</summary>
    /// <param name="appid">AppId</param>
    /// <param name="openid">用户OpenId</param>
    /// <param name="templateid">模板ID</param>
    /// <param name="data">消息数据</param>
    /// <param name="page">跳转页面</param>
    /// <returns></returns>
    public async Task<Boolean> SendSubscribeMessageAsync(String appid, String openid, String templateid, Object data, String page = null)
    {
        if (openid.IsNullOrEmpty()) throw new ArgumentNullException(nameof(openid));
        if (templateid.IsNullOrEmpty()) throw new ArgumentNullException(nameof(templateid));

        // 1. 查询模板配置
        var template = 微信模板消息配置.FindByTemplateId(appid, templateid);
        if (template == null)
        {
            Log?.Warn($"模板 {templateid} 不存在");
            return false;
        }

        if (!template.IsEnabled)
        {
            Log?.Warn($"模板 {templateid} 未启用");
            return false;
        }

        // 2. 获取 AccessToken
        var token = await GetAccessTokenAsync(appid);

        // 3. 构造请求
        var request = new SubscribeMessageRequest
        {
            ToUser = openid,
            Template_Id = templateid,
            Page = page,
            Data = data,
            MiniProgram_State = "formal",
            Lang = "zh_CN"
        };

        // 4. 发送请求
        var apiUrl = $"https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token={token}";
        var result = await PostAsync<WeChatResponse>(apiUrl, request);

        if (!result.IsSuccess)
        {
            Log?.Error($"发送订阅消息失败: {result.ErrCode} - {result.ErrMsg}");
            return false;
        }

        Log?.Info($"订阅消息发送成功");
        return true;
    }

    /// <summary>批量发送模板消息</summary>
    /// <param name="appid">AppId</param>
    /// <param name="openids">用户OpenId列表</param>
    /// <param name="templateid">模板ID</param>
    /// <param name="data">消息数据</param>
    /// <returns></returns>
    public async Task<IDictionary<String, Boolean>> SendBatchTemplateMessagesAsync(String appid, IList<String> openids, String templateid, Object data)
    {
        var results = new Dictionary<String, Boolean>();

        if (openids == null || openids.Count == 0) return results;

        Log?.Info($"批量发送模板消息: {openids.Count} 个用户");

        // 分批处理,每批50个
        var batches = openids.Page(50);

        foreach (var batch in batches)
        {
            var tasks = batch.Select(openid =>
                SendTemplateMessageAsync(appid, openid, templateid, data)
                    .ContinueWith(t => new { OpenId = openid, Success = t.Result })
            );

            var batchResults = await Task.WhenAll(tasks);

            foreach (var result in batchResults)
            {
                results[result.OpenId] = result.Success;
            }
        }

        var successCount = results.Count(r => r.Value);
        Log?.Info($"批量发送完成: 成功 {successCount}/{openids.Count}");

        return results;
    }
    #endregion

    #region HTTP 请求
    /// <summary>GET请求</summary>
    /// <typeparam name="T">响应类型</typeparam>
    /// <param name="url">请求地址</param>
    /// <returns></returns>
    private async Task<T> GetAsync<T>(String url) where T : class
    {
        try
        {
            Log?.Debug($"GET: {url}");

            var response = await _client.GetAsync(url);
            response.EnsureSuccessStatusCode();

            var content = await response.Content.ReadAsStringAsync();
            Log?.Debug($"Response: {content}");

            return content.ToJsonEntity<T>();
        }
        catch (Exception ex)
        {
            Log?.Error($"GET请求失败: {url}, {ex.Message}");
            throw;
        }
    }

    /// <summary>POST请求</summary>
    /// <typeparam name="T">响应类型</typeparam>
    /// <param name="url">请求地址</param>
    /// <param name="body">请求体</param>
    /// <returns></returns>
    private async Task<T> PostAsync<T>(String url, Object body) where T : class
    {
        try
        {
            var json = body.ToJson();
            Log?.Debug($"POST: {url}, Body: {json}");

            var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
            var response = await _client.PostAsync(url, content);
            response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
            Log?.Debug($"Response: {responseContent}");

            return responseContent.ToJsonEntity<T>();
        }
        catch (Exception ex)
        {
            Log?.Error($"POST请求失败: {url}, {ex.Message}");
            throw;
        }
    }
    #endregion

    #region 日志
    /// <summary>写日志</summary>
    /// <param name="message">日志信息</param>
    public void WriteLog(String message)
    {
        Log?.Info(message);
    }
    #endregion
}