C/S架构系统的自动更新功能设计与实现(四)

客户端配置管理

基于WPF开发客户端。配置文件写在config文件中。

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <appSettings>
    <!--升级服务器的IP地址-->
    <add key="ServerIP" value="127.0.0.1" />
    <!--升级服务器的端口号-->
    <add key="ServerPort" value="20000" />
    <!--升级后要调起的应用程序名(与本升级组件同级目录)-->
    <add key="Callback" value="XXXX.XXXX.exe" />
  </appSettings>
    <startup> 
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
    </startup>
</configuration>

配置管理类

/// <summary>
/// 配置项的关键字(名称)
/// </summary>
public class ConfigKey
{
	/// <summary>
	/// 服务器IP
	/// </summary>
	public static string m_sKeyServerIP = "ServerIP";
	/// <summary>
	/// 服务器端口号
	/// </summary>
	public static string m_sKeyServerPort = "ServerPort";
	/// <summary>
	/// 更新后启动的应用程序名
	/// </summary>
	public static string m_sKeyCallback = "Callback";
}
/// <summary>
/// 
/// </summary>
public class ConfigManager
{
	Configuration config = null;
	/// <summary>
	/// 
	/// </summary>
	public ConfigManager()
	{
		config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
	}

	/// <summary>
	/// 
	/// </summary>
	/// <returns></returns>
	public Dictionary<string, string> GetAppSettings()
	{
		var dict = new Dictionary<string, string>();
		config.AppSettings.Settings.AllKeys.ToList().ForEach(p => dict.Add(p, config.AppSettings.Settings[p].Value));
		return dict;
	}

	/// <summary>
	/// //添加键值
	/// </summary>
	/// <param name="key"></param>
	/// <param name="value"></param>
	public void AddAppSetting(string key, object value)
	{
		config.AppSettings.Settings.Add(key, value?.ToString());
		config.Save();
	}

	/// <summary>
	/// //修改键值
	/// </summary>
	/// <param name="key"></param>
	/// <param name="value"></param>
	public void SaveAppSetting(string key, object value)
	{
		config.AppSettings.Settings.Remove(key);
		config.AppSettings.Settings.Add(key, value?.ToString());
		config.Save();
	}

	/// <summary>
	/// //获得键值
	/// </summary>
	/// <param name="key"></param>
	/// <returns></returns>
	public string GetAppSetting(string key)
	{
		return config.AppSettings.Settings[key]?.Value;
	}

	/// <summary>
	/// //移除键值
	/// </summary>
	/// <param name="key"></param>
	public void DeleteAppSetting(string key)
	{
		config.AppSettings.Settings.Remove(key);
		config.Save();
	}
}

功能入口管理

添加一个MainApp,并设置其为启动对象:
设置启动对象
添加参数传递以支持应用程序内部调用该更新程序:

/// <summary>
/// 实际的启动对象
/// </summary>
public class MainApp
{
	/// <summary>
	/// 主函数入口
	/// </summary>
	/// <param name="args">附加参数(要自动关闭的进程名列表)</param>
	[STAThread]
	public static void Main(string[] args)
	{
		try
		{
			#region 关闭传递过来的进程
			args.ToList().ForEach(ar =>
			{
				Process.GetProcessesByName(ar.ToLower().Replace(".exe", "")).ToList().ForEach(p => p.Kill());
			});
			#endregion

			#region 关闭要回调启动的进程
			var sCallback = new ConfigManager().GetAppSetting(ConfigKey.m_sKeyCallback);
			if (!string.IsNullOrEmpty(sCallback))
			{
				var sCallbackName = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, sCallback);
				Process.GetProcessesByName(sCallback.ToLower().Replace(".exe", "")).ToList().ForEach(p =>
				{
					if (0 == string.Compare(p.MainModule.FileName, sCallbackName, StringComparison.OrdinalIgnoreCase))
					{
						p.Kill(); //仅关闭当前目录下的进程(防止错误关闭其他进程)
				}
				});
			}
			#endregion
		}
		catch (Exception)
		{

		}

		#region 启动应用程序
		var app = new App();
		app.InitializeComponent();
		app.Run();
		#endregion
	}
}

界面设计

添加一个简单的WPF界面。
更新功能界面设计

<Window x:Class="XXXX.AutoUpgrade.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="  系统升级" Height="396" Width="640" WindowStartupLocation="CenterScreen" ResizeMode="NoResize" ShowInTaskbar="False" Background="White" WindowStyle="None" Closed="Window_Closed">
    <Grid Height="396" VerticalAlignment="Top" Background="{DynamicResource {x:Static SystemColors.WindowBrushKey}}">
        <Image x:Name="image" HorizontalAlignment="Left" VerticalAlignment="Top" Width="640" Source="Resources/autoupgrade.jpg"/>
        <Label x:Name="labelLocalVersion" Content="当前版本: 1.0.9.0 更新时间: 0000/0/00 00:00:00" Margin="10,10,331,0" VerticalAlignment="Top" RenderTransformOrigin="0.512,-1.28" Height="26" Background="Transparent" FontWeight="Bold">
            <Label.Foreground>
                <SolidColorBrush Color="{DynamicResource {x:Static SystemColors.ControlColorKey}}"/>
            </Label.Foreground>
        </Label>
        <Label x:Name="labelNewVersion" Content="最新版本: 0.0.0.0 发布时间: 0000/0/00 00:00:00" HorizontalAlignment="Right" Margin="0,10,10,0" VerticalAlignment="Top" Width="290" Height="26" Background="Transparent" FontWeight="Bold">
            <Label.Foreground>
                <SolidColorBrush Color="{DynamicResource {x:Static SystemColors.ControlColorKey}}"/>
            </Label.Foreground>
        </Label>
        <ProgressBar x:Name="progressInfo" Margin="10,360,10,0" Background="#FFE6E6E6" BorderThickness="1" Height="4" VerticalAlignment="Top"/>
        <Label x:Name="labelProgressTip" Content="等待中..." Margin="0,330,126,0" RenderTransformOrigin="0.512,-1.28" HorizontalContentAlignment="Left" Height="26" VerticalAlignment="Top" HorizontalAlignment="Right" Width="435" FontWeight="Bold">
            <Label.Foreground>
                <SolidColorBrush Color="{DynamicResource {x:Static SystemColors.ActiveCaptionTextColorKey}}"/>
            </Label.Foreground>
        </Label>
        <Label x:Name="labelProgressFile" Content="等待中..." Margin="0,368,10,3" RenderTransformOrigin="0.512,-1.28" HorizontalContentAlignment="Left" HorizontalAlignment="Right" Width="551" FontWeight="Bold">
            <Label.Foreground>
                <SolidColorBrush Color="{DynamicResource {x:Static SystemColors.ActiveCaptionTextColorKey}}"/>
            </Label.Foreground>
        </Label>
        <Label x:Name="label" Content="更新进度: " HorizontalAlignment="Left" Margin="10,330,0,0" VerticalAlignment="Top" Height="26" Width="64" FontWeight="Bold">
            <Label.Foreground>
                <SolidColorBrush Color="{DynamicResource {x:Static SystemColors.ActiveCaptionTextColorKey}}"/>
            </Label.Foreground>
        </Label>
        <Label x:Name="labelOperation" Content="更新文件: " HorizontalAlignment="Left" Margin="10,368,0,0" VerticalAlignment="Top" Height="26" Width="64" FontWeight="Bold">
            <Label.Foreground>
                <SolidColorBrush Color="{DynamicResource {x:Static SystemColors.ActiveCaptionTextColorKey}}"/>
            </Label.Foreground>
        </Label>
        <Label x:Name="labelProgressValue" Content="0%" HorizontalAlignment="Left" Margin="566,330,0,0" VerticalAlignment="Top" Height="26" Width="64" FontWeight="Bold" RenderTransformOrigin="8.655,0.678" HorizontalContentAlignment="Right">
            <Label.Foreground>
                <SolidColorBrush Color="{DynamicResource {x:Static SystemColors.ActiveCaptionTextColorKey}}"/>
            </Label.Foreground>
        </Label>
    </Grid>
</Window>

更新功能实现

成员变量

        private ConfigManager m_config = new ConfigManager();

        private string m_sValueServerIP = "10.0.1.26";
        private int m_nValueServerPort = 20000;
        private string m_sValueCallback = "";

        private string m_sMainFolder = AppDomain.CurrentDomain.BaseDirectory; //主程序目录

        private string m_sCacheFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UpgradeCache"); //升级文件缓存目录

主窗口

public MainWindow()
{
	InitializeComponent();
	try
	{
		#region 获取配置项中的服务器端IP和端口Port
		var sValueServerIP = m_config.GetAppSetting(ConfigKey.m_sKeyServerIP);
		if (!string.IsNullOrEmpty(sValueServerIP))
		{
			if (IPAddress.TryParse(sValueServerIP, out IPAddress address))
			{
				m_sValueServerIP = sValueServerIP;
			}
			else
			{
				ShowError("config中配置的ServerIP项非法!");
			}
		}
		m_config.SaveAppSetting(ConfigKey.m_sKeyServerIP, m_sValueServerIP);

		var sValueServerPort = m_config.GetAppSetting(ConfigKey.m_sKeyServerPort);
		if (!string.IsNullOrEmpty(sValueServerPort))
		{
			if (int.TryParse(sValueServerPort, out int nPort))
			{
				m_nValueServerPort = nPort;
			}
			else
			{
				ShowError("config中配置的ServerPort项非法!");
			}
		}
		m_config.SaveAppSetting(ConfigKey.m_sKeyServerPort, m_nValueServerPort);
		#endregion

		#region 获取配置项中的回调程序名(位于本自动更新程序同级目录中,如:XXXX.XXXX.exe)
		var sValueCallback = m_config.GetAppSetting(ConfigKey.m_sKeyCallback);
		if (!string.IsNullOrEmpty(sValueCallback))
		{
			m_sValueCallback = sValueCallback;
		}
		m_config.SaveAppSetting(ConfigKey.m_sKeyCallback, m_sValueCallback);
		#endregion

		#region 获取服务器端最新版本摘要
		var sAbstract = RequestWeb("api/Upgrade/Abstract");
		if (!ValidJson.IsJson(sAbstract))
		{
			throw new Exception("获取服务器端最新版本摘要信息失败,请检查升级文件配置及网络状态!");
		}
		var responseInfoAbstract = JsonConvert.DeserializeObject<ResponseInfo>(sAbstract);
		if (ErrorCode.Ok != responseInfoAbstract.Code)
		{
			ShowWarning("未能获取升级信息,请检查升级文件配置及网络状态!");
			Close();
		}
		#endregion

		#region 获取本地版本,与最新版本进行比较
		var localUpgradeInfo = new UpgradeInfo();
		localUpgradeInfo.Load();
		var upgradeInfoAbstract = JsonConvert.DeserializeObject<UpgradeInfoAbstract>(responseInfoAbstract.Data.ToString());
		if (upgradeInfoAbstract.Equals(new UpgradeInfoAbstract(localUpgradeInfo)))
		{
			//ShowInfo("当前版本已是最新版,不需要升级!");
			Close();
		}
		#endregion

		#region 获取升级文件列表
		var sFiles = RequestWeb("api/Upgrade/Files");
		if (!ValidJson.IsJson(sFiles))
		{
			throw new Exception("获取服务器端升级文件信息失败,请检查升级文件配置及网络状态!");
		}
		var responseInfoFilesInfo = JsonConvert.DeserializeObject<ResponseInfo>(sFiles);
		if (ErrorCode.Ok != responseInfoFilesInfo.Code)
		{
			ShowWarning("未能获取升级文件列表,请检查升级文件配置及网络状态!");
			Close();
		}
		var filesInfo = JsonConvert.DeserializeObject<UpgradeFilesInfo>(responseInfoFilesInfo.Data.ToString());
		if (null == filesInfo || null == filesInfo.FileUnits || 0 >= filesInfo.FileUnits.Count)
		{
			ShowWarning("升级文件列表为空,不需要升级!");
			Close();
		}
		#endregion

		#region 升级前先清空缓存,重建缓存目录
		if (Directory.Exists(m_sCacheFolder))
			Directory.Delete(m_sCacheFolder, true);
		Directory.CreateDirectory(m_sCacheFolder);
		#endregion

		#region 执行版本升级
		Upgrade(localUpgradeInfo, upgradeInfoAbstract, filesInfo);
		#endregion
	}
	catch (Exception ex)
	{
		ShowError(ex.Message);
		Close();
	}
}

升级过程

/// <summary>
/// 升级过程
/// </summary>
/// <param name="localUpgradeInfo"></param>
/// <param name="upgradeInfoAbstract"></param>
/// <param name="filesInfo"></param>
private void Upgrade(UpgradeInfo localUpgradeInfo, UpgradeInfoAbstract upgradeInfoAbstract, UpgradeFilesInfo filesInfo)
{
	Task.Factory.StartNew(() =>
	{
		#region 显示升级信息
		var sLocalVersion = $"当前版本: {(string.IsNullOrEmpty(localUpgradeInfo.MainVersion) ? "N/A" : localUpgradeInfo.MainVersion)}  升级时间: {localUpgradeInfo.LastUpgradeTime.ToString()}";
		new Action(() =>
		{
			Dispatcher.BeginInvoke(new Action(() =>
			{
				labelLocalVersion.Content = sLocalVersion;
			}), System.Windows.Threading.DispatcherPriority.SystemIdle, null);
		}).BeginInvoke(null, null);

		var sNewVersion = $"最新版本: {upgradeInfoAbstract.MainVersion}  发布时间: {upgradeInfoAbstract.LastUpgradeTime.ToString()}";
		new Action(() =>
		{
			Dispatcher.BeginInvoke(new Action(() =>
			{
				labelNewVersion.Content = sNewVersion;
			}), System.Windows.Threading.DispatcherPriority.SystemIdle, null);
		}).BeginInvoke(null, null);
		#endregion

		#region 下载升级文件到缓存目录
		var nTotalCount = upgradeInfoAbstract.TotalCount;
		var dTotalSize = Math.Round(upgradeInfoAbstract.TotalSize * 0.001, 2); //单位从KB转为MB(粗略计算,除以1000。保留两位小数)
		var sTotalSize = string.Format("{0:N2}", dTotalSize);
		new Action(() =>
		{
			Dispatcher.BeginInvoke(new Action(() =>
				{
					progressInfo.Minimum = 0;
					progressInfo.Maximum = nTotalCount;
				}), System.Windows.Threading.DispatcherPriority.SystemIdle, null);
		}).BeginInvoke(null, null);

		double dCurrentSize = 0;
		var lstFailedFile = new List<string>(); //升级失败的文件列表
		for (int i = 0; i < nTotalCount; i++)
		{
			var bSkip = false;
			var fileInfo = filesInfo.FileUnits[i];
			dCurrentSize += fileInfo.FileSize;
			var sLocalPath = Path.Combine(m_sMainFolder, fileInfo.RelativePath);
			if (File.Exists(sLocalPath))
			{
				bSkip = new FileUnit(m_sMainFolder, sLocalPath).Equals(fileInfo);
			}
			var sCurrentFile = "";
			if (fileInfo.FileSize < 100) //小于100KB的,按KB显示,否则按MB显示
			{
				sCurrentFile = $"{fileInfo.RelativePath} ({fileInfo.FileSize} KB)";
			}
			else
			{
				sCurrentFile = $"{fileInfo.RelativePath} ({string.Format("{0:N2}", Math.Round(fileInfo.FileSize * 0.001, 2))} MB)";
			}
			if (!bSkip)
			{
				if (!RequestWebFile("api/Upgrade/Stream", fileInfo.RelativePath) && lstFailedFile.Count < 11)
				{
					lstFailedFile.Add(fileInfo.RelativePath);
				}
			}
			new Action(() =>
			{
				Dispatcher.BeginInvoke(new Action(() =>
				{
					var sProgressInfo = $"{(i).ToString("N0")}/{nTotalCount.ToString("N0")}个, {string.Format("{0:N2}", dCurrentSize * 0.001)}/{sTotalSize} MB";
					labelProgressFile.Content = sCurrentFile;
					labelProgressTip.Content = sProgressInfo;
					labelProgressValue.Content = $"{ Math.Round((double)((100 * i) / nTotalCount), 2)} %";
					progressInfo.Value = i + 1;
				}), System.Windows.Threading.DispatcherPriority.SystemIdle, null);
			}).BeginInvoke(null, null);
		}
		#endregion

		#region 判断是否有下载失败的文件
		var sFaileFileList = "";
		var nFailedCount = lstFailedFile.Count > 10 ? 10 : lstFailedFile.Count;
		if (0 < nFailedCount)
		{
			for (int i = 0; i < nFailedCount; i++)
			{
				if (0 == i)
				{
					sFaileFileList += $"[{i + 1}] {lstFailedFile[i]}";
				}
				else
				{
					sFaileFileList += $"{Environment.NewLine}[{i + 1}] {lstFailedFile[i]}";
				}
			}
			if (lstFailedFile.Count > 10)
			{
				sFaileFileList += $"{Environment.NewLine}...";
			}
		}
		#endregion

		#region 文件全部下载后,判断是否更新成功
		if (!string.IsNullOrEmpty(sFaileFileList))
		{
			Dispatcher.Invoke(new Action(delegate
			{
				labelProgressValue.Content = "100%";
				ShowError($"升级失败!以下文件未能正确更新:{Environment.NewLine}{sFaileFileList}");
				Directory.Delete(m_sCacheFolder, true); //删除缓存目录
				Close();
			}));
		}
		else
		{
			new Action(() =>
			{
				Dispatcher.BeginInvoke(new Action(() =>
				{
					labelOperation.Content = "拷贝文件: ";
					progressInfo.Value = 0;
				}), System.Windows.Threading.DispatcherPriority.SystemIdle, null);
			}).BeginInvoke(null, null);
			for (int i = 0; i < nTotalCount; i++) //拷贝文件
			{
				var fileInfo = filesInfo.FileUnits[i];
				var sNewPath = Path.Combine(m_sCacheFolder, fileInfo.RelativePath);
				var sOldPath = Path.Combine(m_sMainFolder, fileInfo.RelativePath);
				new Action(() =>
				{
					Dispatcher.BeginInvoke(new Action(() =>
					{
						var sProgressInfo = $"{i.ToString("N0")}/{nTotalCount.ToString("N0")}";
						labelProgressFile.Content = fileInfo.RelativePath;
						labelProgressTip.Content = sProgressInfo;
						labelProgressValue.Content = $"{ Math.Round((double)((100 * i) / nTotalCount), 2)} %";
						progressInfo.Value = i + 1;
					}), System.Windows.Threading.DispatcherPriority.SystemIdle, null);
				}).BeginInvoke(null, null);

				if (File.Exists(sNewPath))
				{
					if (File.Exists(sOldPath))
					{
						File.Delete(sOldPath);
					}
					var sParentDir = new FileInfo(sOldPath).Directory.FullName;
					if (!Directory.Exists(sParentDir))
						Directory.CreateDirectory(sParentDir);
					File.Move(sNewPath, sOldPath);
				}
			}
			Directory.Delete(m_sCacheFolder, true); //删除缓存目录

			//获取完整版升级信息并保存到本地配置文件
			var sInfo = RequestWeb("api/Upgrade/Info");
			if (ValidJson.IsJson(sInfo))
			{
				var responseInfo = JsonConvert.DeserializeObject<ResponseInfo>(sInfo);
				if (ErrorCode.Ok == responseInfo.Code)
				{
					JsonConvert.DeserializeObject<UpgradeInfo>(responseInfo.Data?.ToString()).Save();
				}
			}

			Dispatcher.Invoke(new Action(delegate
			{
				labelProgressValue.Content = "100%";
				//ShowInfo("升级成功!");
				Close();
			}));
		}
		#endregion
	});
}

其他方法

/// <summary>
/// 发起网络请求
/// </summary>
/// <param name="sRequestAddress">请求地址(不包括IP、端口)</param>
/// <param name="sParam">附加参数</param>
/// <returns>返回结果</returns>
private string RequestWeb(string sRequestAddress, string sParam = null)
{
	string sResponse;
	try
	{
		var sServerIP = m_sValueServerIP;
		if (0 == string.Compare(sServerIP, "127.0.0.1", StringComparison.OrdinalIgnoreCase))
			sServerIP = "localhost";
		var nServerPort = m_nValueServerPort;
		var sUrl = $"http://{sServerIP}:{nServerPort}/{sRequestAddress}";
		if (!string.IsNullOrEmpty(sParam))
		{
			sUrl = $"{sUrl}?param={sParam}";
		}
		var request = (HttpWebRequest)WebRequest.Create(sUrl);
		request.Method = "GET";
		request.ContentType = "application/json";
		var responseStream = request.GetResponse();
		var response = (HttpWebResponse)request.GetResponse();
		var streamReader = new StreamReader(response.GetResponseStream(), Encoding.UTF8);
		sResponse = streamReader.ReadToEnd();
		streamReader.Close();
		if (response != null)
		{
			response.Close();
		}
		if (request != null)
		{
			request.Abort();
		}
	}
	catch (Exception ex)
	{
		sResponse = ex.Message;
	}
	return sResponse;
}

/// <summary>
/// 发起网络请求以下载文件
/// </summary>
/// <param name="sRequestAddress">请求地址(不包括IP、端口)</param>
/// <param name="sFileName">文件名(相对路径)</param>
/// <returns>是否成功</returns>
private bool RequestWebFile(string sRequestAddress, string sFileName)
{
	var bResult = false;
	try
	{
		var sServerIP = m_sValueServerIP;
		if (0 == string.Compare(sServerIP, "127.0.0.1", StringComparison.OrdinalIgnoreCase))
			sServerIP = "localhost";
		var nServerPort = m_nValueServerPort;
		var sUrl = $"http://{sServerIP}:{nServerPort}/{sRequestAddress}";
		if (!string.IsNullOrEmpty(sFileName))
		{
			sUrl = $"{sUrl}?param={sFileName}";
		}
		var request = (HttpWebRequest)WebRequest.Create(sUrl);
		request.Method = "GET";
		var response = (HttpWebResponse)request.GetResponse();
		if (HttpStatusCode.OK == response.StatusCode)
		{
			var sFile = Path.Combine(m_sCacheFolder, sFileName);
			var sParentDir = new FileInfo(sFile).Directory.FullName;
			if (!Directory.Exists(sParentDir))
				Directory.CreateDirectory(sParentDir);
			if (File.Exists(sFile))
				File.Delete(sFile);
			using (var fileStream = File.Create(sFile))
			{
				using (var stream = response.GetResponseStream())
				{
					stream.CopyTo(fileStream);
					bResult = true;
				}
			}
		}
		if (response != null)
		{
			response.Close();
		}
		if (request != null)
		{
			request.Abort();
		}
	}
	catch
	{
		bResult = false;
	}
	return bResult;
}

/// <summary>
/// 窗口关闭后的回调处理
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Window_Closed(object sender, EventArgs e)
{
	if (string.IsNullOrEmpty(m_sValueCallback))
	{
		return;
	}
	var sCallback = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, m_sValueCallback);
	if (!File.Exists(sCallback))
	{
		ShowError($"启动失败,原因:文件不存在!路径:{Environment.NewLine}{sCallback}");
		return;
	}
	System.Diagnostics.Process.Start(sCallback);
}

集成方式

0代码集成!!!
将升级功能组件修改为实际要启动的应用程序名,并将回调应用程序设置为真正的启动对象即可。
比如,我原来的应用程序叫"abc.exe",则将此升级组件改名为“我的ABC.exe”,其回调的应用程序指向原来的"abc.exe"即可。

升级功能界面

功能界面

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值