在System身份运行的.NET程序中以指定的用户身份启动可交互式进程

在System身份运行的.NET程序中以指定的用户身份启动可交互式进程
今天在技术群里,石头哥向大家提了个问题:"如何在一个以System身份运行的.NET程序(Windows Services)中,以其它活动的用户身份启动可交互式进程(桌面应用程序、控制台程序、等带有UI和交互式体验的程序)"?

我以前有过类似的需求,是在GitLab流水线中运行带有UI的自动化测试程序。

其中流水线是GitLab Runner执行的,而GitLab Runner则被注册为Windows服务,以System身份启动的。

然后我在流水线里,巴拉巴拉写了一大串PowerShell脚本代码,通过调用任务计划程序实现了这个需求。

但我没试过在C#里实现这个功能。

对此,我很感兴趣,于是着手研究,最终捣鼓出来了。

二话不多说,上代码:

using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using Microsoft.Win32.SafeHandles;

namespace AllenCai.Windows
{
    /// <summary>
    /// 进程工具类
    /// </summary>
#if NET5_0_OR_GREATER
    [SupportedOSPlatform("windows")]
#endif
    public static class ProcessUtils
    {
        /// <summary>
        /// 在当前活动的用户会话中启动进程
        /// </summary>
        /// <param name="fileName">程序名称或程序路径</param>
        /// <param name="commandLine">命令行参数</param>
        /// <param name="workDir">工作目录</param>
        /// <param name="noWindow">是否无窗口</param>
        /// <param name="minimize">是否最小化</param>
        /// <returns></returns>
        /// <exception cref="ArgumentNullException"></exception>
        /// <exception cref="ApplicationException"></exception>
        /// <exception cref="Win32Exception"></exception>
        public static int StartProcessAsActiveUser(string fileName, string commandLine = null, string workDir = null, bool noWindow = false, bool minimize = false)
        {
            if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentNullException(nameof(fileName));

            // 获取当前活动的控制台会话ID和安全的用户访问令牌
            IntPtr userToken = GetSessionUserToken();
            if (userToken == IntPtr.Zero)
                throw new ApplicationException("Failed to get user token for the active session.");

            IntPtr duplicateToken = IntPtr.Zero;
            IntPtr environmentBlock = IntPtr.Zero;
            try
            {
                // 复制令牌
                SecurityAttributes sa = new SecurityAttributes();
                sa.Length = Marshal.SizeOf(sa);
                if (!DuplicateTokenEx(userToken, MAXIMUM_ALLOWED, ref sa, SecurityImpersonationLevel.SecurityIdentification, TokenType.TokenPrimary, out duplicateToken))
                    throw new ApplicationException("Could not duplicate token.");

                // 创建环境块(检索该用户的环境变量)
                if (!CreateEnvironmentBlock(out environmentBlock, duplicateToken, false))
                    throw new ApplicationException("Could not create environment block.");

                bool theCommandIsInPath;
                // 如果文件名不包含路径分隔符,则尝试先在workDir参数中查找。如果找不到,再在指定用户会话的PATH环境变量中查找。如果还是找不到,则抛出异常
                if ((!fileName.Contains('/') && !fileName.Contains('\\')))
                {
                    if (!string.IsNullOrEmpty(workDir))
                    {
                        if (File.Exists(Path.Combine(workDir, fileName)))
                        {
                            // 在指定的工作目录中找到可执行命令文件
                            theCommandIsInPath = false;
                        }
                        else
                        {
                            // 在指定的工作目录(workDir)中找不到可执行命令文件,再在指定用户会话的PATH环境变量中查找。如果还是找不到,则抛出异常
                            if (!InPathOfSpecificUserEnvironment(in duplicateToken, in environmentBlock, fileName))
                            {
                                throw new ApplicationException($"The file '{fileName}' was not found in the specified directory '{workDir}' or in the PATH environment variable.");
                            }
                            else
                            {
                                // 在指定用户会话的PATH环境变量中找到可执行命令文件
                                theCommandIsInPath = true;
                            }
                        }
                    }
                    else
                    {
                        // 在指定用户会话的PATH环境变量中查找,如果找不到,则抛出异常
                        if (!InPathOfSpecificUserEnvironment(in duplicateToken, in environmentBlock, fileName))
                        {
                            throw new ApplicationException($"The file '{fileName}' was not found in the PATH environment variable.");
                        }
                        // 在指定用户会话的PATH环境变量中找到可执行命令文件
                        theCommandIsInPath = true;
                    }
                }
                else
                {
                    theCommandIsInPath = false;
                }

                string file;
                if (!theCommandIsInPath && !Path.IsPathRooted(fileName))
                {
                    file = !string.IsNullOrEmpty(workDir) ? Path.GetFullPath(Path.Combine(workDir, fileName)) : Path.GetFullPath(fileName);
                }
                else
                {
                    file = fileName;
                }

                if (string.IsNullOrWhiteSpace(workDir)) workDir = theCommandIsInPath ? Environment.CurrentDirectory : Path.GetDirectoryName(file);

                if (string.IsNullOrWhiteSpace(commandLine)) commandLine = "";

                // 启动信息
                ProcessStartInfo psi = new ProcessStartInfo
                {
                    UseShellExecute = true,
                    FileName = $"{file} {commandLine}", //解决带参数的进程起不来或者起来的进程没有参数的问题
                    Arguments = commandLine,
                    WorkingDirectory = workDir,
                    RedirectStandardError = false,
                    RedirectStandardOutput = false,
                    RedirectStandardInput = false,
                    CreateNoWindow = noWindow,
                    WindowStyle = minimize ? ProcessWindowStyle.Minimized : ProcessWindowStyle.Normal
                };

                // 在指定的用户会话中创建进程
                SecurityAttributes saProcessAttributes = new SecurityAttributes();
                SecurityAttributes saThreadAttributes = new SecurityAttributes();
                CreateProcessFlags createProcessFlags = (noWindow ? CreateProcessFlags.CREATE_NO_WINDOW : CreateProcessFlags.CREATE_NEW_CONSOLE) | CreateProcessFlags.CREATE_UNICODE_ENVIRONMENT;
                bool success = CreateProcessAsUser(duplicateToken, null, $"{file} {commandLine}", ref saProcessAttributes, ref saThreadAttributes, false, createProcessFlags, environmentBlock, null, ref psi, out ProcessInformation pi);
                if (!success)
                {
                    throw new Win32Exception(Marshal.GetLastWin32Error());
                    //throw new ApplicationException("Could not create process as user.");
                }

                return pi.dwProcessId;
            }
            finally
            {
                // 清理资源
                if (userToken != IntPtr.Zero) CloseHandle(userToken);
                if (duplicateToken != IntPtr.Zero) CloseHandle(duplicateToken);
                if (environmentBlock != IntPtr.Zero) DestroyEnvironmentBlock(environmentBlock);
            }
        }

        /// <summary>
        /// 使用win32api实现在指定用户身份的环境变量中查找命令(command参数)是否存在。
        /// </summary>
        private static bool InPathOfSpecificUserEnvironment(in IntPtr userToken, in IntPtr environmentBlock, in string command)
        {
            // 在指定用户会话环境中执行命令,并且获得控制台标准输出内容
            string commandLine = $"cmd.exe /c chcp 65001 && where {command}";
            string output = ExecuteCommandAsUserAndReturnStdOutput(userToken, environmentBlock, commandLine, Encoding.UTF8);

            // OperatingSystem.IsOSPlatform("WINDOWS") 该方法仅在 .NET Core及以上版本可用,在 .NET Standard 和 .NET Framework 中不可用。
            // 现有操作系统中,Windows 操作系统的目录分隔符为 '\',而 Unix 操作系统的目录分隔符为 '/',因此可以用它来判断和区分操作系统。
            // 如果是Windows操作系统,则不区分大小写
            var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
            return output.IndexOf(command, comparison) >= 0;
        }

        /// <summary>
        /// 在指定用户会话环境中执行命令,并且返回控制台标准输出内容
        /// </summary>
        private static string ExecuteCommandAsUserAndReturnStdOutput(in IntPtr userToken, in IntPtr environmentBlock, string commandLine, Encoding encoding)
        {
            // 创建匿名管道
            var saPipeAttributes = new SecurityAttributes();
            saPipeAttributes.Length = Marshal.SizeOf(saPipeAttributes);
            saPipeAttributes.InheritHandle = true; // 允许句柄被继承
                                                   //saPipeAttributes.SecurityDescriptor = IntPtr.Zero;
            if (!CreatePipe(out IntPtr readPipe, out IntPtr writePipe, ref saPipeAttributes, 0))
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }

            // 确保管道句柄有效
            if (readPipe == IntPtr.Zero)
            {
                throw new InvalidOperationException("Failed to create read pipe.");
            }
            if (writePipe == IntPtr.Zero)
            {
                throw new InvalidOperationException("Failed to create write pipe.");
            }

            try
            {
                // 确保读取句柄不被子进程继承
                SetHandleInformation(readPipe, 0x00000001/*HANDLE_FLAG_INHERIT*/, 0);

                var startInfo = new StartupInfo();
                startInfo.cb = Marshal.SizeOf(startInfo);
                // 设置子进程的标准输出为管道的写入端
                startInfo.hStdError = writePipe;
                startInfo.hStdOutput = writePipe;
                startInfo.dwFlags = StartupInfoFlags.STARTF_USESTDHANDLES;

                // 在用户会话中创建进程
                const CreateProcessFlags createProcessFlags = CreateProcessFlags.CREATE_NEW_CONSOLE | CreateProcessFlags.CREATE_UNICODE_ENVIRONMENT;
                var success = CreateProcessAsUser(
                    userToken,
                    null,
                    commandLine,
                    ref saPipeAttributes,
                    ref saPipeAttributes,
                    true,
                    createProcessFlags,
                    environmentBlock,
                    null,
                    ref startInfo,
                    out ProcessInformation pi);
                if (!success)
                {
                    throw new Win32Exception(Marshal.GetLastWin32Error());
                }

                // 关闭管道的写入端句柄,因为它已经被子进程继承
                CloseHandle(writePipe);
                writePipe = IntPtr.Zero;

                // 从管道的读取端读取数据
                string output;
                using (var streamReader = new StreamReader(new FileStream(new SafeFileHandle(readPipe, true), FileAccess.Read, 4096, false), encoding))
                {
                    // 读取控制台标准输出内容
                    output = streamReader.ReadToEnd();
                    Trace.WriteLine($"The commandLine [{commandLine}] std output -> {output}");
                }

                // 关闭进程和线程句柄
                CloseHandle(pi.hProcess);
                CloseHandle(pi.hThread);

                // 返回控制台标准输出内容
                return output;
            }
            finally
            {
                if (readPipe != IntPtr.Zero) CloseHandle(readPipe);
                if (writePipe != IntPtr.Zero) CloseHandle(writePipe);
            }
        }

        /// <summary>
        /// 获取活动会话的用户访问令牌
        /// </summary>
        /// <exception cref="Win32Exception"></exception>
        private static IntPtr GetSessionUserToken()
        {
            // 获取当前活动的控制台会话ID
            uint sessionId = WTSGetActiveConsoleSessionId();

            // 获取活动会话的用户访问令牌
            bool success = WTSQueryUserToken(sessionId, out IntPtr hToken);
            // 如果失败,则从会话列表中获取第一个活动的会话ID,并再次尝试获取用户访问令牌
            if (!success)
            {
                sessionId = GetFirstActiveSessionOfEnumerateSessions();
                success = WTSQueryUserToken(sessionId, out hToken);
                if (!success)
                    throw new Win32Exception(Marshal.GetLastWin32Error());
            }

            return hToken;
        }

        /// <summary>
        /// 枚举所有用户会话,获取第一个活动的会话ID
        /// </summary>
        private static uint GetFirstActiveSessionOfEnumerateSessions()
        {
            IntPtr pSessionInfo = IntPtr.Zero;
            try
            {
                int sessionCount = 0;

                // 枚举所有用户会话
                if (WTSEnumerateSessions(IntPtr.Zero, 0, 1, ref pSessionInfo, ref sessionCount) != 0)
                {
                    int arrayElementSize = Marshal.SizeOf(typeof(WtsSessionInfo));
                    IntPtr current = pSessionInfo;

                    for (int i = 0; i < sessionCount; i++)
                    {
                        WtsSessionInfo si = (WtsSessionInfo)Marshal.PtrToStructure(current, typeof(WtsSessionInfo));
                        current += arrayElementSize;

                        if (si.State == WtsConnectStateClass.WTSActive)
                        {
                            return si.SessionID;
                        }
                    }
                }

                return uint.MaxValue;
            }
            finally
            {
                WTSFreeMemory(pSessionInfo);
                CloseHandle(pSessionInfo);
            }
        }

        [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
        private static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, ref SecurityAttributes lpProcessAttributes, ref SecurityAttributes lpThreadAttributes, bool bInheritHandles, CreateProcessFlags dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref StartupInfo lpStartupInfo, out ProcessInformation lpProcessInformation);

        /// <summary>
        /// 以指定用户的身份启动进程
        /// </summary>
        [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
        private static extern bool CreateProcessAsUser(
            IntPtr hToken,
            string lpApplicationName,
            string lpCommandLine,
            ref SecurityAttributes lpProcessAttributes,
            ref SecurityAttributes lpThreadAttributes,
            bool bInheritHandles,
            CreateProcessFlags dwCreationFlags,
            IntPtr lpEnvironment,
            string lpCurrentDirectory,
            ref ProcessStartInfo lpStartupInfo,
            out ProcessInformation lpProcessInformation);

        /// <summary>
        /// 获取当前活动的控制台会话ID
        /// </summary>
        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern uint WTSGetActiveConsoleSessionId();

        /// <summary>
        /// 枚举所有用户会话
        /// </summary>
        [DllImport("wtsapi32.dll", SetLastError = true)]
        private static extern int WTSEnumerateSessions(IntPtr hServer, int reserved, int version, ref IntPtr ppSessionInfo, ref int pCount);

        /// <summary>
        /// 获取活动会话的用户访问令牌
        /// </summary>
        [DllImport("wtsapi32.dll", SetLastError = true)]
        private static extern bool WTSQueryUserToken(uint sessionId, out IntPtr phToken);

        /// <summary>
        /// 复制访问令牌
        /// </summary>
        [DllImport("advapi32.dll", SetLastError = true)]
        private static extern bool DuplicateTokenEx(IntPtr hExistingToken, uint dwDesiredAccess, ref SecurityAttributes lpTokenAttributes, SecurityImpersonationLevel impersonationLevel, TokenType tokenType, out IntPtr phNewToken);

        /// <summary>
        /// 创建环境块(检索指定用户的环境)
        /// </summary>
        [DllImport("userenv.dll", SetLastError = true)]
        private static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit);

        /// <summary>
        /// 释放环境块
        /// </summary>
        [DllImport("userenv.dll", SetLastError = true)]
        private static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern bool CreatePipe(out IntPtr hReadPipe, out IntPtr hWritePipe, ref SecurityAttributes lpPipeAttributes, uint nSize);

        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern bool SetHandleInformation(IntPtr hObject, uint dwMask, uint dwFlags);

        [DllImport("wtsapi32.dll", SetLastError = false)]
        private static extern void WTSFreeMemory(IntPtr memory);

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern bool CloseHandle(IntPtr hObject);

        [StructLayout(LayoutKind.Sequential)]
        private struct WtsSessionInfo
        {
            public readonly uint SessionID;

            [MarshalAs(UnmanagedType.LPStr)]
            public readonly string pWinStationName;

            public readonly WtsConnectStateClass State;
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct SecurityAttributes
        {
            public int Length;
            public IntPtr SecurityDescriptor;
            public bool InheritHandle;
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct StartupInfo
        {
            public int cb;
            public string lpReserved;
            public string lpDesktop;
            public string lpTitle;
            public uint dwX;
            public uint dwY;
            public uint dwXSize;
            public uint dwYSize;
            public uint dwXCountChars;
            public uint dwYCountChars;
            public uint dwFillAttribute;
            public StartupInfoFlags dwFlags;
            public UInt16 wShowWindow;
            public UInt16 cbReserved2;
            public unsafe byte* lpReserved2;
            public IntPtr hStdInput;
            public IntPtr hStdOutput;
            public IntPtr hStdError;
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct ProcessInformation
        {
            public IntPtr hProcess;
            public IntPtr hThread;
            public int dwProcessId;
            public int dwThreadId;
        }

        private const uint TOKEN_DUPLICATE = 0x0002;
        private const uint MAXIMUM_ALLOWED = 0x2000000;

        /// <summary>
        /// Process Creation Flags。<br/>
        /// More:https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
        /// </summary>
        [Flags]
        private enum CreateProcessFlags : uint
        {
            DEBUG_PROCESS = 0x00000001,
            DEBUG_ONLY_THIS_PROCESS = 0x00000002,
            CREATE_SUSPENDED = 0x00000004,
            DETACHED_PROCESS = 0x00000008,
            /// <summary>
            /// The new process has a new console, instead of inheriting its parent's console (the default). For more information, see Creation of a Console. <br />
            /// This flag cannot be used with <see cref="DETACHED_PROCESS"/>.
            /// </summary>
            CREATE_NEW_CONSOLE = 0x00000010,
            NORMAL_PRIORITY_CLASS = 0x00000020,
            IDLE_PRIORITY_CLASS = 0x00000040,
            HIGH_PRIORITY_CLASS = 0x00000080,
            REALTIME_PRIORITY_CLASS = 0x00000100,
            CREATE_NEW_PROCESS_GROUP = 0x00000200,
            /// <summary>
            /// If this flag is set, the environment block pointed to by lpEnvironment uses Unicode characters. Otherwise, the environment block uses ANSI characters.
            /// </summary>
            CREATE_UNICODE_ENVIRONMENT = 0x00000400,
            CREATE_SEPARATE_WOW_VDM = 0x00000800,
            CREATE_SHARED_WOW_VDM = 0x00001000,
            CREATE_FORCEDOS = 0x00002000,
            BELOW_NORMAL_PRIORITY_CLASS = 0x00004000,
            ABOVE_NORMAL_PRIORITY_CLASS = 0x00008000,
            INHERIT_PARENT_AFFINITY = 0x00010000,
            INHERIT_CALLER_PRIORITY = 0x00020000,
            CREATE_PROTECTED_PROCESS = 0x00040000,
            EXTENDED_STARTUPINFO_PRESENT = 0x00080000,
            PROCESS_MODE_BACKGROUND_BEGIN = 0x00100000,
            PROCESS_MODE_BACKGROUND_END = 0x00200000,
            CREATE_BREAKAWAY_FROM_JOB = 0x01000000,
            CREATE_PRESERVE_CODE_AUTHZ_LEVEL = 0x02000000,
            CREATE_DEFAULT_ERROR_MODE = 0x04000000,
            /// <summary>
            /// The process is a console application that is being run without a console window. Therefore, the console handle for the application is not set. <br />
            /// This flag is ignored if the application is not a console application, or if it is used with either <see cref="CREATE_NEW_CONSOLE"/> or <see cref="DETACHED_PROCESS"/>.
            /// </summary>
            CREATE_NO_WINDOW = 0x08000000,
            PROFILE_USER = 0x10000000,
            PROFILE_KERNEL = 0x20000000,
            PROFILE_SERVER = 0x40000000,
            CREATE_IGNORE_SYSTEM_DEFAULT = 0x80000000,
        }

        /// <summary>
        /// 指定创建进程时的窗口工作站、桌面、标准句柄和main窗口的外观。<br/>
        /// More:https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/ns-processthreadsapi-startupinfoa
        /// </summary>
        [Flags]
        private enum StartupInfoFlags : uint
        {
            /// <summary>
            /// 强制反馈光标显示,即使用户没有启用。
            /// </summary>
            STARTF_FORCEONFEEDBACK = 0x00000040,
            /// <summary>
            /// 强制反馈光标不显示,即使用户启用了它。
            /// </summary>
            STARTF_FORCEOFFFEEDBACK = 0x00000080,
            /// <summary>
            /// 防止应用程序被固定在任务栏或开始菜单。
            /// </summary>
            STARTF_PREVENTPINNING = 0x00002000,
            /// <summary>
            /// 不再支持,原用于强制控制台应用程序全屏运行。
            /// </summary>
            STARTF_RUNFULLSCREEN = 0x00000020,
            /// <summary>
            /// lpTitle成员是一个AppUserModelID。
            /// </summary>
            STARTF_TITLEISAPPID = 0x00001000,
            /// <summary>
            /// lpTitle成员是一个链接名。
            /// </summary>
            STARTF_TITLEISLINKNAME = 0x00000800,
            /// <summary>
            /// 启动程序来自不受信任的源,可能会显示警告。
            /// </summary>
            STARTF_UNTRUSTEDSOURCE = 0x00008000,
            /// <summary>
            /// 使用dwXCountChars和dwYCountChars成员。
            /// </summary>
            STARTF_USECOUNTCHARS = 0x00000008,
            /// <summary>
            /// 使用dwFillAttribute成员。
            /// </summary>
            STARTF_USEFILLATTRIBUTE = 0x00000010,
            /// <summary>
            /// 使用hStdInput成员指定热键。
            /// </summary>
            STARTF_USEHOTKEY = 0x00000200,
            /// <summary>
            /// 使用dwX和dwY成员。
            /// </summary>
            STARTF_USEPOSITION = 0x00000004,
            /// <summary>
            /// 使用wShowWindow成员。
            /// </summary>
            STARTF_USESHOWWINDOW = 0x00000001,
            /// <summary>
            /// 使用dwXSize和dwYSize成员。
            /// </summary>
            STARTF_USESIZE = 0x00000002,
            /// <summary>
            /// 使用hStdInput、hStdOutput和hStdError成员。
            /// </summary>
            STARTF_USESTDHANDLES = 0x00000100
        }

        private enum WtsConnectStateClass
        {
            WTSActive,
            WTSConnected,
            WTSConnectQuery,
            WTSShadow,
            WTSDisconnected,
            WTSIdle,
            WTSListen,
            WTSReset,
            WTSDown,
            WTSInit
        }

        private enum SecurityImpersonationLevel
        {
            SecurityAnonymous,
            SecurityIdentification,
            SecurityImpersonation,
            SecurityDelegation
        }

        private enum TokenType
        {
            TokenPrimary = 1,
            TokenImpersonation
        }
    }
}

用法:

ProcessUtils.StartProcessAsActiveUser("ping.exe", "www.baidu.com -t");
ProcessUtils.StartProcessAsActiveUser("notepad.exe");
ProcessUtils.StartProcessAsActiveUser("C:\Windows\System32\notepad.exe");
在 Windows 7~11、Windows Server 2016~2022 操作系统,测试通过。

之前的代码还有些问题,如果传入的是一个没有路径的命令,且workDir参数不为空时,则会被拼接,导致启动时找不到这个命令。
增加检查传入的命令在指定的用户会话环境中是否存在,如果不存在,则抛出异常vb.net教程C#教程python教程SQL教程access 2010教程xin3721自学网

作者:VAllen

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值