微信上货 增加视频上传

This commit is contained in:
chfei committed 2026-08-12 18:27:52 +08:00
1 parent 4d38059e72
commit 3611d9a8e9
3 files changed
+8186 -7468

No files matched your search

+671
View File
@@ -0,0 +1,671 @@
using AllBase;
using AllBase.Model.Result;
using Fiddler;
using NaiBa.Util.Extension;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace TemuKJD.BLL
{
internal static class Config
{
// ------- Cookie -------
// ------- 以下是协议里的固定常量,一般不用改 -------
public const string VideoHost = "https://westoregamma.video.qq.com";
public const string ShopHost = "https://store.weixin.qq.com";
public const int BizId = 50087; // 微信小店商品视频固定 bizId
public const int AppType = 162; // 视频场景固定 apptype
public const int FileType = 20302; // 视频场景固定 filetype
public const int ChunkSize = 2 * 1024 * 1024; // 分片大小 2MB,与抓包一致
// 轮询转码结果的间隔与最大次数
public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
public const int MaxPollTimes = 30;
}
public class WXVideoUploader
{
private string Cookie = "";
// ------- 当前登录会话的 openid -------
private string OpenId = "";
// ------- 商品 ID, groupid -------
private string ProductId = "";
private string biz_magic = "";
private readonly HttpClient _http;
public WXVideoUploader()
{
_http = new HttpClient(new HttpClientHandler
{
Proxy = new WebProxy("http://127.0.0.1:8080"),
UseProxy = true,
UseCookies=false,
AutomaticDecompression = System.Net.DecompressionMethods.GZip
| System.Net.DecompressionMethods.Deflate
});
}
public WXVideoUploader(HttpClient http)
{
_http = http;
}
public MultiMediaDownloadUrlResp Upload(ShopLoginInfo shop, string productId, string videoPath)
{
if (!File.Exists(videoPath))
{
throw new Exception("视频文件不存在");
}
byte[] bs = File.ReadAllBytes(videoPath);
return Upload(shop, productId, bs);
}
public MultiMediaDownloadUrlResp Upload(ShopLoginInfo shop, string productId, byte[] videoBytes)
{
var cookieLst = JsonHelper.DeserializeJsonToList<CefSharp.Cookie>(shop?.Cookies.ParseToString());
if (cookieLst == null)
cookieLst = new List<CefSharp.Cookie>();
foreach (var cook in cookieLst)
{
if (cook.Name == "biz_magic")
{
biz_magic = cook.Value;
}
Cookie += cook.Name + "=" + cook.Value + "; ";
}
OpenId = shop.openid;
ProductId = productId;
int fileSize = videoBytes.Length;
if (fileSize == 0)
{
throw new Exception("视频数据错误");
}
// taskId 贯穿整个上传流程的 X-Arguments,fileId 与之相同
string taskId = Guid.NewGuid().ToString();
Debug.WriteLine($"[0/6] GetUploadTempImgAuth");
TempImgAuthResp authResp = GetUploadTempImgAuth();
Debug.WriteLine($"[1/6] initupload fileSize={fileSize} taskId={taskId}");
InitUploadResp initResp = InitUpload(taskId, fileSize);
Debug.WriteLine($" -> fileName={initResp.FileName} uKey={initResp.UKey} videoId={initResp.VideoId}");
Debug.WriteLine("[2/6] uploadpart 分片上传...");
List<FinishPart> finishParts = UploadParts(taskId, videoBytes, initResp.FileName, initResp.UKey, fileSize);
Debug.WriteLine($" -> 共 {finishParts.Count} 片上传完成");
Debug.WriteLine("[3/6] finishupload 提交分片清单...");
FinishUpload(taskId, initResp.FileName, initResp.UKey, finishParts);
Debug.WriteLine("[4/6] transcode 触发转码...");
Transcode(taskId, initResp.FileName);
Debug.WriteLine("[5/6] getplayinfo 轮询转码结果...");
// 转码后的文件名规则: <videoId>.f100001.mp4
string transcodedFileName = $"{initResp.VideoId}.f100001.mp4";
string playUrl = PollPlayUrl(taskId, transcodedFileName);
Debug.WriteLine($" -> playUrl={playUrl}");
Debug.WriteLine("[6/6] getMultiMediaDownloadUrl 换取最终下载地址...");
MultiMediaDownloadUrlResp finalResult = GetMultiMediaDownloadUrl(playUrl);
finalResult.VideoDuration = GetMp4Duration(videoBytes).Seconds;
return finalResult;
}
// -------------------------------------------------------------------
// getUploadTempImgAuth
// -------------------------------------------------------------------
private TempImgAuthResp GetUploadTempImgAuth(string uploadScene = "100")
{
string url = $"{Config.ShopHost}/shop-faas/mmecchannelspicture/cgi/getUploadTempImgAuth" +
$"?token=&lang=zh_CN&uploadScene={uploadScene}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.TryAddWithoutValidation("Cookie", Cookie);
req.Headers.TryAddWithoutValidation("potter-scene", "weixinShop");
req.Headers.TryAddWithoutValidation("wecom_magic", "");
req.Headers.TryAddWithoutValidation("supplier_magic", "");
req.Headers.TryAddWithoutValidation("talent_magic", "");
req.Headers.TryAddWithoutValidation("logistics_magic", "");
req.Headers.TryAddWithoutValidation("mcn_magic", "");
req.Headers.TryAddWithoutValidation("biz_magic", biz_magic);
req.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*");
req.Headers.Referrer = new Uri($"{Config.ShopHost}/shop/goods/entry?productId={ProductId}");
using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult();
string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
EnsureSuccessJson(resp, json);
var parsed = JsonConvert.DeserializeObject<TempImgAuthResp>(json, JsonOpts)!;
if (parsed.Code != 0)
throw new Exception($"getUploadTempImgAuth 失败: {json}");
return parsed;
}
// -------------------------------------------------------------------
// 1. initupload
// -------------------------------------------------------------------
private InitUploadResp InitUpload(string taskId, long fileSize)
{
var body = new
{
bizId = Config.BizId,
fileUpInfo = new
{
fileId = taskId,
fileType = "mp4",
fileSize
}
};
using var req = BuildVideoRequest(HttpMethod.Post, "/initupload", taskId);
req.Content = JsonContent(body);
using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult();
string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
EnsureSuccessJson(resp, json);
var parsed = JsonConvert.DeserializeObject<InitUploadResp>(json, JsonOpts)!;
if (parsed.Code != 0)
throw new Exception($"initupload 失败: {json}");
return parsed;
}
// -------------------------------------------------------------------
// 2. uploadpart(按 2MB 分片,partnum 从 1 开始)
// -------------------------------------------------------------------
private List<FinishPart> UploadParts(string taskId, byte[] videoBytes, string fileName, string uKey, long fileSize)
{
var finishParts = new List<FinishPart>();
using var fs = new MemoryStream(videoBytes);
byte[] buffer = new byte[Config.ChunkSize];
int partNum = 1;
long remaining = fileSize;
while (remaining > 0)
{
int toRead = (int)Math.Min(Config.ChunkSize, remaining);
int readTotal = 0;
while (readTotal < toRead)
{
int n = fs.Read(buffer, readTotal, toRead - readTotal);
if (n <= 0) throw new IOException("读取文件失败,文件可能被截断");
readTotal += n;
}
byte[] chunk = readTotal == buffer.Length ? buffer : buffer.Take(readTotal).ToArray();
string path = $"/uploadpart?filename={Uri.EscapeDataString(fileName)}&ukey={Uri.EscapeDataString(uKey)}&partnum={partNum}";
using var req = BuildVideoRequest(HttpMethod.Post, path, taskId);
req.Content = new ByteArrayContent(chunk);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); // 与抓包一致(实际发的是二进制)
using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult();
string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
EnsureSuccessJson(resp, json);
var parsed = JsonConvert.DeserializeObject<UploadPartResp>(json, JsonOpts)!;
if (parsed.Code != 0)
throw new Exception($"uploadpart(part {partNum}) 失败: {json}");
Console.WriteLine($" part {partNum}: {readTotal} bytes, sha1(server)={parsed.PartSha}");
finishParts.Add(new FinishPart { PartNum = partNum, PartSha = parsed.PartSha });
remaining -= readTotal;
partNum++;
}
return finishParts;
}
// -------------------------------------------------------------------
// 3. finishupload
// -------------------------------------------------------------------
private void FinishUpload(string taskId, string fileName, string uKey, List<FinishPart> finishParts)
{
var body = new
{
bizId = Config.BizId,
fileName,
uKey,
finishParts,
transInfo = new
{
snapshot = new[] { new { formatId = 1400 } }, // 封面截图
transcode = new[] { new { formatId = 100003 }, new { formatId = 100004 } }
}
};
using var req = BuildVideoRequest(HttpMethod.Post, "/finishupload", taskId);
req.Content = JsonContent(body);
using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult();
string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
EnsureSuccessJson(resp, json);
var parsed = JsonConvert.DeserializeObject<CodeMsgResp>(json, JsonOpts)!;
if (parsed.Code != 0)
throw new Exception($"finishupload 失败: {json}");
}
// -------------------------------------------------------------------
// 4. transcode(触发最终可播放版本 formatId=100001)
// -------------------------------------------------------------------
private void Transcode(string taskId, string fileName)
{
var body = new
{
fileName,
bizId = Config.BizId,
transInfo = new { transcode = new[] { new { formatId = 100001 } } },
use_new_transcode = 1
};
using var req = BuildVideoRequest(HttpMethod.Post, "/transcode", taskId);
req.Content = JsonContent(body);
using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult();
string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
EnsureSuccessJson(resp, json);
var parsed = JsonConvert.DeserializeObject<CodeMsgResp>(json, JsonOpts)!;
if (parsed.Code != 0)
throw new Exception($"transcode 失败: {json}");
}
// -------------------------------------------------------------------
// 5. getplayinfo 轮询(转码未完成时返回 code=404,完成后 code=0 带 playUrl)
// -------------------------------------------------------------------
private string PollPlayUrl(string taskId, string transcodedFileName)
{
for (int i = 0; i < Config.MaxPollTimes; i++)
{
string path = $"/getplayinfo?filename={Uri.EscapeDataString(transcodedFileName)}";
using var req = BuildVideoRequest(HttpMethod.Get, path, taskId);
using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult();
string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
var parsed = JsonConvert.DeserializeObject<GetPlayInfoResp>(json, JsonOpts)!;
if (parsed.Code == 0 && parsed.FormatList != null && parsed.FormatList.Count > 0)
{
string? url = parsed.FormatList[0].PlayUrl;
if (!string.IsNullOrEmpty(url))
return url;
}
Console.WriteLine($" 转码中... ({i + 1}/{Config.MaxPollTimes}) code={parsed.Code}");
Thread.Sleep(Config.PollInterval);
}
throw new TimeoutException("等待转码超时,请增大 Config.MaxPollTimes 或检查视频云状态");
}
// -------------------------------------------------------------------
// 6. store.weixin.qq.com getMultiMediaDownloadUrl
// -------------------------------------------------------------------
private MultiMediaDownloadUrlResp GetMultiMediaDownloadUrl(string playUrl)
{
var url = $"{Config.ShopHost}/shop-faas/mmecchannelspicture/cgi/getMultiMediaDownloadUrl?token=&lang=zh_CN";
using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.TryAddWithoutValidation("Cookie", Cookie);
req.Headers.TryAddWithoutValidation("potter-scene", "weixinShop");
// 抓包里这几个 magic 头是空字符串,但仍然带上
req.Headers.TryAddWithoutValidation("wecom_magic", "");
req.Headers.TryAddWithoutValidation("supplier_magic", "");
req.Headers.TryAddWithoutValidation("talent_magic", "");
req.Headers.TryAddWithoutValidation("logistics_magic", "");
req.Headers.TryAddWithoutValidation("mcn_magic", "");
req.Headers.TryAddWithoutValidation("biz_magic", biz_magic);
req.Headers.Referrer = new Uri($"{Config.ShopHost}/shop/goods/entry?productId={ProductId}");
req.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*");
req.Content = JsonContent(new { playUrl });
using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult();
string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
EnsureSuccessJson(resp, json);
var parsed = JsonConvert.DeserializeObject<MultiMediaDownloadUrlResp>(json, JsonOpts)!;
if (parsed.Code != 0)
throw new Exception($"getMultiMediaDownloadUrl 失败: {json}");
return parsed;
}
// -------------------------------------------------------------------
// 公共辅助方法
// -------------------------------------------------------------------
/// <summary>
/// 构造 westoregamma.video.qq.com 上的请求,统一带上 X-Arguments 等公共头
/// </summary>
private HttpRequestMessage BuildVideoRequest(HttpMethod method, string pathAndQuery, string taskId)
{
var req = new HttpRequestMessage(method, Config.VideoHost + pathAndQuery);
string xArguments =
$"apptype={Config.AppType}&filetype={Config.FileType}&openid={OpenId}&groupid={ProductId}&taskid={taskId}";
req.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest");
req.Headers.TryAddWithoutValidation("X-Arguments", xArguments);
req.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*");
req.Headers.Referrer = new Uri($"{Config.ShopHost}/");
req.Headers.TryAddWithoutValidation("Origin", Config.ShopHost);
return req;
}
private static HttpContent JsonContent(object body)
{
string json = JsonConvert.SerializeObject(body);
return new StringContent(json, Encoding.UTF8, "application/json");
}
private static void EnsureSuccessJson(HttpResponseMessage resp, string json)
{
if (!resp.IsSuccessStatusCode)
throw new Exception($"HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}: {json}");
}
#region 获取video时长
/// <summary>
/// 从 MP4 字节数组中读取视频时长
/// </summary>
/// <param name="videoBytes">完整的 MP4 文件字节数组</param>
/// <returns>视频时长</returns>
private static TimeSpan GetMp4Duration(byte[] videoBytes)
{
if (videoBytes == null || videoBytes.Length < 8)
throw new ArgumentException("无效的 MP4 数据", nameof(videoBytes));
int offset = 0;
while (offset + 8 <= videoBytes.Length)
{
ulong boxSize = ReadUInt32BE(videoBytes, offset);
string boxType = Encoding.ASCII.GetString(
videoBytes, offset + 4, 4);
int headerSize = 8;
// Extended Size
if (boxSize == 1)
{
if (offset + 16 > videoBytes.Length)
throw new InvalidOperationException("MP4 Box 数据不完整");
boxSize = ReadUInt64BE(videoBytes, offset + 8);
headerSize = 16;
}
// size == 0 表示一直到文件末尾
else if (boxSize == 0)
{
boxSize = (ulong)(videoBytes.Length - offset);
}
if (boxSize < (ulong)headerSize ||
boxSize > (ulong)(videoBytes.Length - offset))
{
throw new InvalidOperationException(
"无效的 MP4 Box: " + boxType);
}
// 找到 moov
if (boxType == "moov")
{
int moovStart = offset + headerSize;
int moovLength = checked((int)boxSize - headerSize);
return ParseMoov(
videoBytes,
moovStart,
moovLength);
}
offset += checked((int)boxSize);
}
throw new InvalidOperationException(
"MP4 中没有找到 moov Box");
}
private static TimeSpan ParseMoov(
byte[] data,
int start,
int length)
{
int offset = start;
int end = start + length;
while (offset + 8 <= end)
{
ulong boxSize = ReadUInt32BE(data, offset);
string boxType = Encoding.ASCII.GetString(
data,
offset + 4,
4);
int headerSize = 8;
if (boxSize == 1)
{
if (offset + 16 > end)
throw new InvalidOperationException(
"MP4 Box 数据不完整");
boxSize = ReadUInt64BE(data, offset + 8);
headerSize = 16;
}
else if (boxSize == 0)
{
boxSize = (ulong)(end - offset);
}
if (boxSize < (ulong)headerSize ||
boxSize > (ulong)(end - offset))
{
throw new InvalidOperationException(
"无效的 MP4 Box: " + boxType);
}
// moov -> mvhd
if (boxType == "mvhd")
{
return ParseMvhd(
data,
offset + headerSize,
checked((int)boxSize - headerSize));
}
offset += checked((int)boxSize);
}
throw new InvalidOperationException(
"MP4 中没有找到 mvhd Box");
}
private static TimeSpan ParseMvhd(
byte[] data,
int start,
int length)
{
if (length < 20)
throw new InvalidOperationException(
"mvhd 数据不完整");
// version
byte version = data[start];
// Version 0
if (version == 0)
{
// version + flags 4
// creation_time 4
// modification_time 4
// timescale 4
// duration 4
uint timescale = ReadUInt32BE(
data,
start + 12);
uint duration = ReadUInt32BE(
data,
start + 16);
if (timescale == 0)
throw new InvalidOperationException(
"MP4 timescale 为 0");
return TimeSpan.FromSeconds(
(double)duration / timescale);
}
// Version 1
if (version == 1)
{
if (length < 32)
throw new InvalidOperationException(
"mvhd version 1 数据不完整");
// version + flags 4
// creation_time 8
// modification_time 8
// timescale 4
// duration 8
uint timescale = ReadUInt32BE(
data,
start + 20);
ulong duration = ReadUInt64BE(
data,
start + 24);
if (timescale == 0)
throw new InvalidOperationException(
"MP4 timescale 为 0");
return TimeSpan.FromSeconds(
(double)duration / timescale);
}
throw new NotSupportedException(
"不支持的 mvhd version: " + version);
}
private static uint ReadUInt32BE(
byte[] data,
int offset)
{
return (uint)(
(data[offset] << 24) |
(data[offset + 1] << 16) |
(data[offset + 2] << 8) |
data[offset + 3]);
}
private static ulong ReadUInt64BE(
byte[] data,
int offset)
{
uint high = ReadUInt32BE(data, offset);
uint low = ReadUInt32BE(data, offset + 4);
return ((ulong)high << 32) | low;
}
#endregion
private static readonly JsonSerializerSettings JsonOpts = new JsonSerializerSettings
{
};
}
internal class TempImgAuthResp
{
[JsonProperty("code")] public int Code { get; set; }
[JsonProperty("disK")] public string DisK { get; set; } = "";
[JsonProperty("disT")] public long DisT { get; set; }
[JsonProperty("ext")] public string Ext { get; set; } = "";
[JsonProperty("url")] public string Url { get; set; } = "";
}
internal class InitUploadResp
{
[JsonProperty("code")] public int Code { get; set; }
[JsonProperty("fileName")] public string FileName { get; set; } = "";
[JsonProperty("uKey")] public string UKey { get; set; } = "";
[JsonProperty("videoId")] public string VideoId { get; set; } = "";
[JsonProperty("msg")] public string Msg { get; set; }
}
internal class UploadPartResp
{
[JsonProperty("code")] public int Code { get; set; }
[JsonProperty("msg")] public string Msg { get; set; }
[JsonProperty("partSha")] public string PartSha { get; set; } = "";
}
internal class FinishPart
{
[JsonProperty("partNum")] public int PartNum { get; set; }
[JsonProperty("partSha")] public string PartSha { get; set; } = "";
}
internal class CodeMsgResp
{
[JsonProperty("code")] public int Code { get; set; }
[JsonProperty("msg")] public string Msg { get; set; }
}
internal class GetPlayInfoResp
{
[JsonProperty("code")] public int Code { get; set; }
[JsonProperty("msg")] public string Msg { get; set; }
[JsonProperty("formatList")] public List<PlayFormat>? FormatList { get; set; }
}
internal class PlayFormat
{
[JsonProperty("formatId")] public int FormatId { get; set; }
[JsonProperty("playUrl")] public string PlayUrl { get; set; }
[JsonProperty("duration")] public double Duration { get; set; }
[JsonProperty("width")] public int Width { get; set; }
[JsonProperty("height")] public int Height { get; set; }
}
public class MultiMediaDownloadUrlResp
{
[JsonProperty("code")] public int Code { get; set; }
[JsonProperty("videoImgUrl")] public string VideoImgUrl { get; set; }
[JsonProperty("downloadUrl")] public string DownloadUrl { get; set; }
/// <summary>
/// 时长
/// </summary>
public int VideoDuration { get; set; }
}
}
+3 -2
View File
@@ -63,7 +63,7 @@
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DebugType>full</DebugType>
<PlatformTarget>x64</PlatformTarget>
<LangVersion>7.3</LangVersion>
<LangVersion>8.0</LangVersion>
<ErrorReport>prompt</ErrorReport>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
@@ -73,7 +73,7 @@
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
<PlatformTarget>x64</PlatformTarget>
<LangVersion>7.3</LangVersion>
<LangVersion>8.0</LangVersion>
<ErrorReport>prompt</ErrorReport>
<Prefer32Bit>true</Prefer32Bit>
</PropertyGroup>
@@ -155,6 +155,7 @@
<Compile Include="BLL\VDian\VDianUpProdMappingBLL.cs" />
<Compile Include="BLL\VDian\VDianProductBLL.cs" />
<Compile Include="BLL\WxApiBLL.cs" />
<Compile Include="BLL\WXVideoUploader.cs" />
<Compile Include="BLL\Wx\WxPlatformRecommendProdManage.cs" />
<Compile Include="BLL\YkdHttpResponseBLL.cs" />
<Compile Include="BLL\WXHttpResponseBLL.cs" />
File diff suppressed because it is too large. Load diff