Files
KJD/Work/Api/Wx/WxHttpClient.cs
2026-10-30 11:30:02 +08:00

220 lines
13 KiB
C#

using AllBase;
using AllBase.Model.Result;
using CsharpHttpHelper;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
namespace Work.Api.Wx
{
/// <summary>微信小店公共 HTTP 请求层,使用指定来源的 CsharpHttpHelper.HttpHelper。</summary>
/// <remarks>根据原 WXHttpResponseBLL 保留不同场景的 Cookie 和请求头。同步返回原始正文或错误文字;不更新 ShopLoginInfo、不重试。新业务优先调用分类 API。</remarks>
public static class WxHttpClient
{
/// <summary>微信小店后台 HTTPS 根地址。</summary>
public const string ShopHost = "https://store.weixin.qq.com";
// 原视频上传器的店铺请求发送完整 Cookie,并额外包含 logistics_magic。
internal static string SendMedia(ShopLoginInfo shop, string path, string productId, string data = null)
{
return Send(() =>
{
var item = CreateRequest(shop, ShopHost + path, ShopHost + "/shop/goods/entry?productId=" + Uri.EscapeDataString(productId),
data == null ? MethodType.Get : MethodType.Post, data);
var cookies = JsonHelper.DeserializeJsonToList<CefSharp.Cookie>(Convert.ToString(shop.Cookies)) ?? new List<CefSharp.Cookie>();
item.Cookie = string.Concat(cookies.Select(c => c.Name + "=" + c.Value + "; "));
item.Header["logistics_magic"] = "";
return item;
});
}
/// <summary>发送微信 GET 请求,沿用 WeiXinGetResponse 仅发送 biz 前缀 Cookie 的约定。</summary>
/// <param name="shop">店铺登录信息,Cookies 为 CefSharp.Cookie 列表 JSON。</param>
/// <param name="url">store.weixin.qq.com 域名下的完整 HTTPS 请求地址。</param>
/// <param name="referer">来源页面;普通接口使用原页面地址,默认空字符串。</param>
/// <param name="isGetDocument">true 模拟首页文档导航,并忽略 referer;false 模拟普通接口请求。</param>
/// <returns>原始 JSON、HTML 或错误文字;调用方判断格式和业务状态。</returns>
public static string Get(ShopLoginInfo shop, string url, string referer = "", bool isGetDocument = false)
{
return Send(() => CreateRequest(shop, url, referer, MethodType.Get, document: isGetDocument));
}
/// <summary>发送微信 JSON POST 请求,携带完整 Cookie 和 biz_magic,对应 WeiXinPostResponse。</summary>
/// <param name="shop">店铺登录信息,Cookies 为 CefSharp.Cookie 列表 JSON。</param>
/// <param name="url">store.weixin.qq.com 域名下的完整 HTTPS 请求地址。</param>
/// <param name="data">完整 JSON 请求正文;按 UTF-8 编码,发送前检查 JSON 格式。</param>
/// <param name="referer">原业务页面 Referer。</param>
/// <returns>原始响应正文或错误文字,不自动重试写操作。</returns>
public static string Post(ShopLoginInfo shop, string url, string data, string referer)
{
return Send(() => CreateRequest(shop, url, referer, MethodType.Post, data));
}
/// <summary>发送带货助手请求,对应 WeiXinAssistantPostResponse,使用 talent_magic 而非 biz_magic 请求头。</summary>
/// <param name="shop">带货助手登录信息,Cookies 必须包含对应 talent_magic 凭证。</param>
/// <param name="url">store.weixin.qq.com 域名下的完整 HTTPS 请求地址。</param>
/// <param name="data">完整 UTF-8 JSON 请求正文。</param>
/// <param name="referer">带货助手页面 Referer。</param>
/// <returns>原始响应正文或错误文字,由调用方检查业务状态。</returns>
public static string PostAssistant(ShopLoginInfo shop, string url, string data, string referer)
{
return Send(() => CreateRequest(shop, url, referer, MethodType.Post, data, assistant: true));
}
/// <summary>发送达人客服会话请求,对应 WeiXinTalentOpenKfPostResponse,补充 user_scene=1002、kf_scene=6。</summary>
/// <param name="shop">店铺登录信息,Cookies 为 CefSharp.Cookie 列表 JSON。</param>
/// <param name="url">store.weixin.qq.com 域名下的完整 HTTPS 请求地址。</param>
/// <param name="data">完整 UTF-8 JSON 请求正文。</param>
/// <param name="referer">原达人客服业务页面 Referer。</param>
/// <returns>原始响应正文或错误文字,由调用方检查业务状态。</returns>
public static string PostCustomerService(ShopLoginInfo shop, string url, string data, string referer)
{
return Send(() =>
{
var item = CreateRequest(shop, url, referer, MethodType.Post, data);
item.Header["user_scene"] = "1002";
item.Header["kf_scene"] = "6";
item.Header["logistics_magic"] = "";
return item;
});
}
/// <summary>对应 WeiXinImgPostResponse,将 Base64 解码为原始字节上传,不附带店铺 Cookie。</summary>
/// <param name="shop">店铺登录信息,openid 用于 X-Arguments 上传签名上下文。</param>
/// <param name="url">westorealpha.video.qq.com 域名下的完整 HTTPS 图片上传地址。</param>
/// <param name="base64">纯 Base64 图片字符串,不含 data URI 前缀。</param>
/// <param name="dis_k">临时上传凭证 dis_k。</param>
/// <param name="dis_t">临时上传凭证 dis_t,原值透传。</param>
/// <param name="ext">临时上传凭证 ext,原值透传。</param>
/// <param name="referer">来源页面,默认微信小店根地址。</param>
/// <returns>原始响应正文或错误文字;无效 Base64 不会发起请求。</returns>
public static string UploadImage(ShopLoginInfo shop, string url, string base64, string dis_k, long dis_t,
string ext, string referer = ShopHost + "/")
{
return Send(() =>
{
// 图片上传使用独立的视频域名和临时签名,不发送店铺 Cookie。
var item = CreateRequest(shop, url, referer, MethodType.Post, image: true);
item.Header["X-Requested-With"] = "XMLHttpRequest";
item.Header["X-Arguments"] = "dis_k=" + dis_k + "&dis_t=" + dis_t + "&ext=" + ext +
"&apptype=160&filetype=20304&openid=" + shop.openid + "&groupid=0&taskid=" + Guid.NewGuid();
item.PostDataType = PostDataType.Byte;
item.PostdataByte = Convert.FromBase64String(base64);
return item;
});
}
/// <summary>对应 WeiXinUploadResponse,构造 multipart 二进制正文并发送 biz 前缀 Cookie。</summary>
/// <param name="shop">店铺登录信息,Cookies 为 CefSharp.Cookie 列表 JSON。</param>
/// <param name="url">store.weixin.qq.com 域名下的完整 HTTPS 上传地址。</param>
/// <param name="body">原 multipart 文本模板;成对的 &lt;!&gt; 标记之间为本地文件完整路径,文本统一换行为 CRLF。</param>
/// <param name="boundary">与正文分隔符一致的 multipart boundary,不含正文额外的两个前导短横线。</param>
/// <param name="referer">原上传页面 Referer。</param>
/// <returns>原始响应正文或错误文字;文件不可读或标记不完整时不发送请求。</returns>
public static string UploadMultipart(ShopLoginInfo shop, string url, string body, string boundary, string referer)
{
return Send(() =>
{
var item = CreateRequest(shop, url, referer, MethodType.Post, bizCookiesOnly: true);
item.ContentType = "multipart/form-data; boundary=" + boundary;
item.PostDataType = PostDataType.Byte;
item.PostdataByte = BuildMultipartBody(body);
return item;
});
}
internal static byte[] BuildMultipartBody(string body)
{
if (body == null) throw new ArgumentNullException(nameof(body));
using (var stream = new MemoryStream())
{
// 保留原上传协议:<!> 包围的片段为本地文件路径,其余片段规范化为 CRLF。
var parts = body.Split(new[] { "<!>" }, StringSplitOptions.None);
if (parts.Length % 2 == 0) throw new ArgumentException("上传文件标记必须成对出现。", nameof(body));
for (int i = 0; i < parts.Length; i++)
{
string part = parts[i];
byte[] bytes = i % 2 == 1 ? File.ReadAllBytes(part) :
Encoding.UTF8.GetBytes(part.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n"));
stream.Write(bytes, 0, bytes.Length);
}
return stream.ToArray();
}
}
internal static HttpItem CreateRequest(ShopLoginInfo shop, string url, string referer, MethodType method,
string data = null, bool assistant = false, bool document = false, bool image = false, bool bizCookiesOnly = false)
{
if (shop == null) throw new ArgumentNullException(nameof(shop));
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps ||
(image ? uri.Host != "westorealpha.video.qq.com" : uri.Host != "store.weixin.qq.com"))
throw new ArgumentException("微信请求地址不在允许的 HTTPS 域名内。", nameof(url));
if (data != null) Newtonsoft.Json.Linq.JToken.Parse(data);
var cookies = JsonHelper.DeserializeJsonToList<CefSharp.Cookie>(Convert.ToString(shop.Cookies)) ?? new List<CefSharp.Cookie>();
var item = new HttpItem
{
URL = url,
Method = method,
Referer = document ? "" : referer,
Accept = document ? "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" : "application/json, text/plain, */*",
ContentType = method == MethodType.Post ? "application/json" : null,
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
Encoding = Encoding.UTF8,
PostEncoding = Encoding.UTF8,
Postdata = data,
PostDataType = PostDataType.String,
KeepAlive = true,
Expect100Continue = false,
Allowautoredirect = true,
ReadWriteTimeout = 300000,
Cookie = image ? "" : string.Concat(cookies.Where(c => !(method == MethodType.Get || bizCookiesOnly) || c.Name.StartsWith("biz", StringComparison.Ordinal)).Select(c => c.Name + "=" + c.Value + ";")),
Header = new WebHeaderCollection()
};
item.Header["sec-ch-ua"] = "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\"";
item.Header["sec-ch-ua-mobile"] = "?0";
item.Header["sec-ch-ua-platform"] = "\"Windows\"";
item.Header["Sec-Fetch-Site"] = document ? "none" : image ? "same-site" : "same-origin";
item.Header["Sec-Fetch-Mode"] = document ? "navigate" : "cors";
item.Header["Sec-Fetch-Dest"] = document ? "document" : "empty";
// HttpHelper 只解压 gzip,因此不宣告支持 br、zstd、deflate。
item.Header[HttpRequestHeader.AcceptEncoding] = "gzip";
item.Header[HttpRequestHeader.AcceptLanguage] = method == MethodType.Post && !image && !bizCookiesOnly ?
"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6" : "zh-CN,zh;q=0.9";
if (document)
{
item.Header["Upgrade-Insecure-Requests"] = "1";
item.Header["Sec-Fetch-User"] = "?1";
}
else if (!image)
{
item.Header["wecom_magic"] = "";
item.Header["supplier_magic"] = "";
item.Header["mcn_magic"] = "";
item.Header["potter-scene"] = "weixinShop";
item.Header["biz_magic"] = assistant ? "" : cookies.FirstOrDefault(c => c.Name == "biz_magic")?.Value ?? "";
item.Header["talent_magic"] = assistant ? cookies.FirstOrDefault(c => c.Name == "talent_magic")?.Value ?? "" : "";
}
if (method == MethodType.Post) item.Header["Origin"] = ShopHost;
return item;
}
private static string Send(Func<HttpItem> createRequest)
{
try
{
return HttpHelper.Request(createRequest()).Html;
}
catch (Exception ex)
{
// 兼容现有调用约定:传输失败返回错误文字,业务失败返回原 JSON。
return ex.Message;
}
}
}
}