基于DL/T645-07协议的电表数据采集终端

本文总结自两年前我负责的一个小项目,完全自主架构,同时也是所开发的唯一一个非环保行业数据采集软件,我觉得很有必要记录下。

一、前言

工作中经手的数采系统也不算少了,之前接触到的都是环境监测行业的数据采集,直到两年前接收了一个远程采集智能电表电能数据的项目。

由于行业不同,外加采集方式有所区别,此前数采系统的架构均不适合,需要重新设计一个结构。

当时我开发的这套电表数采,在这个项目中充当数据源的角色,仅需要实现智能电表的数据采集、统计和保存,展示和应用分析不在要求之内。也就是说,我只需要保证数据正常入库即可。

二、数据采集流程

之前说过,智能电表的数据采集方式跟我之前接触过的环保行业有所区别。

2.1 环保行业数据采集流程

环保行业(环境空气地表水)现场都具备工控机,由工控机跟分析仪通过串口或网口直接通讯,采集完数据后,按照HJ212-2017协议组包上传给中心服务器,大致的流程如下图:
在这里插入图片描述

2.2 电表数据采集流程

对于智能电表而言,现场不配备工控机,直接通过DTU实现透传。每个电表有一个唯一地址,即中心服务器可直接通过DTU与其下挂载的电表直接通讯,大致如下图所示。
在这里插入图片描述
由于DTU数量是不确定的,后期可能会加装,联网状态也是不确定的,程序启动时无法预知有效客户端的数量。因此,设计时需要考虑动态挂载。

首先,DTU需要配置服务器的IP和端口号,并在上线后向服务器发送一个注册包,约定好注册包的内容为此DTU的ID。这个ID是人为定义的,且必须唯一,其作用是在服务器上能识别出是哪个DTU上线。

此外,单个DTU下可挂载多个电表,DTU和电表对的对应关系需要提前在数据库中配置好,一旦DTU上线后成功识别出注册包,根据配置创建出此DTU下所有挂载的电表,由服务器根据电表地址,主动轮询取数。

解析出数据后,每隔5分钟或者10分钟保存一组瞬时数据,直接入库。

三、DL/T645-07协议解析

DL/T645协议是针对电表通信而制定的通信协议,主要有两个版本,分别是DL/T645-97和DL/T645-07,目前最新版本是2007版,而项目中电表虽然有多个幸好,但采用的都是07版本。因此,仅需要兼容这一个版本的协议即可。

有点类似ModBus协议,格式如下:
在这里插入图片描述
据此设计如下的出解析算法。

public bool TryParse(byte[] data)
{
    // 校验包头和包尾
    if (data[0] != 0x68 && data[data.Length - 1] != 0x16)
    {
        return false;
    }

    // 校验和
    byte[] dest = new byte[data.Length - 2];
    Buffer.BlockCopy(data, 0, dest, 0, data.Length - 2);
    if (CheckSum(dest) != data[data.Length - 2])
    {
        return false;
    }

    // 校验地址
    bool isSameAddress = true;
    for (int i = 0; i < m_Address.Length; i++)
    {
        if (m_Address[i] != dest[i + 1])
        {
            isSameAddress = false;
            break;
        }
    }
    if (!isSameAddress)
    {
        return false;
    }

    // 校验数据长度
    int dataLen = dest[9];
    int realLen = dest.Length - 10;
    if (dataLen != realLen)
    {
        return false;
    }

    // 截取数据段
    byte[] realData = new byte[dataLen];
    Buffer.BlockCopy(dest, 10, realData, 0, dataLen);
    // 数据包-0x33
    List<byte> dataFlagMinus33 = new List<byte>();
    for (int i = 0; i < realData.Length; i++)
    {
        realData[i] -= 0x33;
        if (i < 4)
        {
            dataFlagMinus33.Add(realData[i]);
        }
    }

    // DataFlag倒置回来
    dataFlagMinus33.Reverse();
    for (int i = 0; i < 4; i++)
    {
        realData[i] = dataFlagMinus33[i];
    }

    return Parse(realData);
}

/// <summary>
/// 和校验
/// </summary>
protected static short CheckSum(IEnumerable<byte> data)
{
    int result = 0;
    foreach (byte b in data)
    {
        result += b;
    }
    return (byte)(result % 256);
}

四、数据采集模块设计

按照之前的开发习惯,将电表抽象为仪器Device,需要采集的数据抽象为因子Factor,DTU抽象为总线Bus。

4.1 因子类定义

public class Factor
{
    /// <summary>
    /// 电表地址
    /// </summary>
    public string TerminalID { get; private set; }

    /// <summary>
    /// 电表内挂载因子的索引
    /// </summary>
    public int IndexInDevice { get; private set; }

    /// <summary>
    /// 名称
    /// </summary>
    public string Name { get; private set; }

    /// <summary>
    /// 单位
    /// </summary>
    public string Unit { get; private set; }

    /// <summary>
    /// 统计系数,
    /// </summary>
    private float RealValuePara { get; set; }

    /// <summary>
    /// 原始数据
    /// </summary>
    public Data RawData { get; private set; }

    /// <summary>
    /// 构造
    /// </summary>
    public Factor(string terminalID, int indexInDevice, string name, string unit, float realValuePara)
    {
        TerminalID = terminalID;
        IndexInDevice = indexInDevice;
        Name = name;
        Unit = unit;
        RealValuePara = realValuePara;
        RawData = new Data();
    }

    public void SetData(float rawValue)
    {
        RawData.DataTime = DateTime.Now;
        RawData.RawValue = rawValue;
        RawData.Value = rawValue * RealValuePara;
    }
}

4.2 仪器类定义

为了方便解析,定义可解析645协议的父类Base645Driver

public class Base645Driver
{
    /// <summary>
    /// 电流互感器
    /// </summary>
    public float CT { get; private set; }

    /// <summary>
    /// 电压互感器
    /// </summary>
    public float PT { get; private set; }

    /// <summary>
    /// 已配置的数据
    /// </summary>
    public List<Factor> Factors
    {
        get { return this.m_Factors; }
    }

    /// <summary>
    /// 所有支持的数据
    /// </summary>
    public virtual List<ChannelConfig> AllSupportedChannels
    {
        get { return new List<ChannelConfig>(); }
    }

    /// <summary>
    /// 构造
    /// </summary>
    public Base645Driver(string address, float ct, float pt)
    {
            ...
    }

    /// <summary>
    /// 初始化
    /// </summary>
    public void Init()
    {
        ...
    }

    /// <summary>
    /// 生成取数命令
    /// </summary>
    public bool MakeCmd(out byte[] cmd)
    {
        ...
    }

    /// <summary>
    /// 尝试解析收到的数据包
    /// </summary>
    public bool TryParse(byte[] data)
    {
        ...
    }
}

以DTSD483智能电表为例,只需要继承此类,定义内部通道即可。

/// <summary>
/// DTSD483电表驱动
/// </summary>
public class DTSD483Driver : Base645Driver
{
    public DTSD483Driver(string address, float ct, float pt)
        : base(address, ct, pt)
    {
    }

    public override List<ChannelConfig> AllSupportedChannels
    {
        get
        {
            List<ChannelConfig> channels = new List<ChannelConfig>();
            // tpe 组合有功总电能  kWh  XXXXXX.XX 二次侧值,真实值=tpe*PT*CT
            channels.Add(new ChannelConfig("组合有功总电能", "kWh", 0, new byte[] { 0x00, 0x00, 0x00, 0x00 }, 2, PT * CT));
            // tqe 组合无功1总电能  kVarh  XXXXXX.XX 二次侧值,真实值=tqe*PT*CT
            channels.Add(new ChannelConfig("组合无功1总电能", "kVarh", 1, new byte[] { 0x00, 0x03, 0x00, 0x00 }, 2, PT * CT));
            // tqe 组合无功2总电能  kVarh  XXXXXX.XX 二次侧值,真实值=tqe*PT*CT
            channels.Add(new ChannelConfig("组合无功2总电能", "kVarh", 2, new byte[] { 0x00, 0x04, 0x00, 0x00 }, 2, PT * CT));

            // Ia A相电流值,单位A,二次侧值,真实值=Ia*CT
            channels.Add(new ChannelConfig("A相电流", "A", 3, new byte[] { 0x02, 0x02, 0x01, 0x00 }, 3, CT));
            // Ua A相电压值,单位V,二次侧值,真实值=Ua*PT
            channels.Add(new ChannelConfig("A相电压", "V", 4, new byte[] { 0x02, 0x01, 0x01, 0x00 }, 1, PT));
            // Pa A相有功功率值,单位kW,二次侧值,真实值=Pa*PT*CT
            channels.Add(new ChannelConfig("A相有功功率", "kW", 5, new byte[] { 0x02, 0x03, 0x01, 0x00 }, 4, PT * CT));
            // PFa A相功率因数
            channels.Add(new ChannelConfig("A相功率因数", "", 6, new byte[] { 0x02, 0x06, 0x01, 0x00 }, 3, 1));
            // Qa A相无功功率值,单位kVar,二次侧值,真实值=Qa*PT*CT
            channels.Add(new ChannelConfig("A相无功功率", "kVar", 7, new byte[] { 0x02, 0x04, 0x01, 0x00 }, 4, PT * CT));

            ...

            return channels;
        }
    }

    protected override bool Parse(byte[] data)
    {
        int factorIndex = -1;

        #region 电能
        // 组合有功总电能  kWh  XXXXXX.XX
        if (data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x00 && data[3] == 0x00)
        {
            factorIndex = m_Factors[0].IndexInDevice;
        }
        // 组合无功1总电能
        else if (data[0] == 0x00 && data[1] == 0x03 && data[2] == 0x00 && data[3] == 0x00)
        {
            factorIndex = m_Factors[1].IndexInDevice;
        }
        // 组合无功1总电能
        else if (data[0] == 0x00 && data[1] == 0x04 && data[2] == 0x00 && data[3] == 0x00)
        {
            factorIndex = m_Factors[2].IndexInDevice;
        }
            #endregion

        #region A相
        // Ia A相电流值,单位A,二次侧值,真实值=Ia*CT
        else if (data[0] == 0x02 && data[1] == 0x02 && data[2] == 0x01 && data[3] == 0x00)
        {
            factorIndex = m_Factors[3].IndexInDevice;
        }
        // A相电压值
        else if (data[0] == 0x02 && data[1] == 0x01 && data[2] == 0x01 && data[3] == 0x00)
        {
            factorIndex = m_Factors[4].IndexInDevice;
        }
        // A相有功功率值
        else if (data[0] == 0x02 && data[1] == 0x03 && data[2] == 0x01 && data[3] == 0x00)
        {
            factorIndex = m_Factors[5].IndexInDevice;
        }
        // A相功率因数
        else if (data[0] == 0x02 && data[1] == 0x06 && data[2] == 0x01 && data[3] == 0x00)
        {
            factorIndex = m_Factors[6].IndexInDevice;
        }
        // A相无功功率值
        else if (data[0] == 0x02 && data[1] == 0x04 && data[2] == 0x01 && data[3] == 0x00)
        {
            factorIndex = m_Factors[7].IndexInDevice;
        }
        #endregion

        ...

        else
        {
            return base.Parse(data);
        }

        if (factorIndex < 0)
        {
            return false;
        }

        float value = Data.DEFAULT_VALUE;
        if (!DoParseValue(data, m_AllSupportedChannels[factorIndex].PointLen, out value))
        {
            return false;
        }

        m_Factors[factorIndex].SetData(value);
        return true;
    }
}

4.3 总线定义

最后就是总线Bus的设计,总线需要实现挂载/移除仪器、定时轮询取数、电表通讯状态通知等功能。

大致结构如下。

public delegate void CommMessageArrivedHandler(string terminalID, bool isSend, bool? parseResult, byte[] data);

public delegate List<Device> LoadDeviceHandler(string regpack);

public delegate void BusClosingHandler(string busGuid);

public class Bus : IDisposable
{
    public event CommMessageArrivedHandler CommMessageArrived;
    public event LoadDeviceHandler LoadDevice;
    public event BusClosingHandler Closing;

    public string BusGuid { get; set; }

    public DateTime LastCommTime { get; set; }

    public bool Enabled
    {
        get { return m_Enabled; }
        set
        {
            this.m_SendTimer.Enabled = value;
            StartSample(value);
        }
    }

    private bool m_Connected = true;
    public bool Connected
    {
        get
        {
            if (m_Socket != null)
            {
                return m_Connected && !((m_Socket.Poll(1000, SelectMode.SelectRead) && (m_Socket.Available == 0)) || !m_Socket.Connected);
            }
            else
            {
                return false;
            }
        }
    }

    /// <summary>
    /// 获取总线上启用的仪器个数
    /// </summary>
    public int EnabledDeviceCount
    {
        get { return m_Device.Count(a => a.Enabled); }
    }

    /// <summary>
    /// 获取总线上启用的仪器个数
    /// </summary>
    public Bus(Socket socket)
    {
        BusGuid = Guid.NewGuid().ToString();
        m_Socket = socket;
        LastCommTime = DateTime.Now;

        m_SendTimer.Interval = 2000;
        m_SendTimer.Elapsed += SendTimer_Elapsed;
        m_SendTimer.Start();
    }

    public void Close()
    {
        if (m_Socket != null)
        {
            m_Socket.Close();
        }

        OnClosing();
    }

    public void RemoveDevice(Device device)
    {
        ...
    }

    public void Dispose()
    {
        ...
    }
}

程序启动时开启Socket监听,当有新的客户端连入时,解析注册包,并生成总线,总线内部由定时器定时轮询,发送指令向电表取数。

由于是基于Socket的通讯,存在Socket.IsConnectedTrue,而实际上链路已经断开的情况。在总线中实现了重连机制,通过判断此连接最后一次通讯成功的时间距当前时间超过设定的超时时间,来强制掐断此链接,卸载Bus下挂载的Device和Factor,注销此Bus,等待客户端重连。

五、数据统计模块设计

与采集类似,设计出统计模块的父类ProcSevice

/// <summary>
/// 数据处理共用父类
/// </summary>
public abstract class ProcService : BaseMySqlDA
{
    /// <summary>
    /// 目标电表
    /// </summary>
    protected List<Device> m_Devices = new List<Device>();

    /// <summary>
    /// 保存周期(分钟)
    /// </summary>
    public virtual int SaveInterval { get; protected set; } = 5;

    /// <summary>
    /// 构造函数,挂载电表
    /// </summary>
    /// <param name="device"></param>
    public ProcService(List<Device> device)
        : base(ConnectionStringManager.EmsDataDB)
    {
        m_Devices.AddRange(device);
    }

    /// <summary>
    /// 子类实现具体的计算逻辑
    /// </summary>
    /// <param name="now"></param>
    public abstract void Process(DateTime now);
}

不同型号的电表,采集的指标不同,有的电表只能出组合有功电能,还有的电表可以出完整的三相数据。按照客户要求,需要保存到独立的表中。因此,具体的逻辑交给子类实现,主程序只需要根据不同的电表型号出初始化不同的保存模块即可。

六、界面展示

本程序的侧重点在数据采集和保存,对界面的要求不高。但为了方便查看实时数据、电表在线状态和配置,我还是做了几个简单的界面。

6.1 电表实时数据和在线状况查看

实时监控界面如下,虽然朴素,但可以很清晰的看到每个电表的数据情况和在线状态。
在这里插入图片描述
左侧列出了所有的DTU,以及DTU下挂载的电表,可通过左侧的通讯状态指示灯判断电表的通讯状态。

  • 红灯代表离线或解析失败。
  • 绿灯代表解析成功

主界面右上角可以打开/关闭选中电表的实时通讯报文以及解析结果。

主界面右下角可能清楚地看到电表在线状态的统计信息。

6.2 电表信息管理

此界面可以配置电表信息及相应的DTU注册包。电表台账信息来自客户另一个数据库,因此本程序没有实现新增功能,读取现有的电表记录后,我方程序内配置并保存通讯参数。
在这里插入图片描述
双击行可配置通讯解析参数。
在这里插入图片描述

6.3 日志查询

程序会记录注册包通讯故障/恢复以及系统启动/退出三类运行日志,并提供查询。
在这里插入图片描述

七、稳定性和可靠性

作为一个数采系统,数据采集和传输的稳定性尤其重要。为保存程序7*24无故障运行,对于一些可预见的异常,必须及时处理。

7.1 检测端口占用

启动时检测到Socket端口被占用这种致命的问题,不应该直接将异常抛给用户,给出一个提示会友好很多。
在这里插入图片描述

7.2 单进程启动

Socket服务端通讯程序不允许同时启动多个实例,我们可以使用Mutex来确保只启动一个进程。

// 单例模式启动系统
bool canCreateNew = true;
int retryCount = 0;
do
{
    if (RunMutex == null)
    {
        RunMutex = new Mutex(true, "EmsGetway", out canCreateNew);
    }
    else
    {
        canCreateNew = RunMutex.WaitOne(100, true);
    }

    retryCount++;
}
while (!canCreateNew && retryCount <= 20);

if (!canCreateNew)
{
    MessageUtilEx.ShowInfo(null, "程序已经在运行。");
    Application.Exit();
    return;
}

main方法中加入上面的代码,启动时检测是否已经存在同名的Mutex,如果尝试20次之后依旧存在,则认为已经有一个实例正在运行,弹出消息框提示用户。
在这里插入图片描述

7.3 未处理异常

程序在运行过程中,还会有许多不可预见的异常,如某个组件被误删除,数据库服务挂了,等等。可以通过订阅下面的事件来保证异常发生后及时记录日志,方便后期排查故障。

// 未处理异常捕获
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
Application.ThreadException += new ThreadExceptionEventHandler(Application_ThreadException);

两个事件处理程序中都是调用了HandleException方法来记录日志。

/// <summary>
/// 未处理异常处理
/// </summary>
/// <param name="exceptionObj">异常对象</param>
private static void HandleException(object exceptionObj)
{
    string logMsg = null, titleMsg = null, attachMsg = null;
    if (exceptionObj is FileNotFoundException fileNotFoundException)
    {
        string fileName = fileNotFoundException.FileName;
        logMsg = "无法找到以下文件,程序即将退出。\r\n文件名:" + fileName;
        titleMsg = "缺失文件,程序即将退出。";
        attachMsg = logMsg;
        LogUtil.WriteLog(typeof(App).FullName, fileNotFoundException);
    }
    else if (exceptionObj is Exception exception)
    {
        logMsg = "发生错误,程序即将退出。\r\n异常信息:" + exception.Message + "\r\n堆栈:" + exception.StackTrace;
        titleMsg = "发生错误,程序即将退出。";
        attachMsg = "异常信息:" + exception.Message + "\r\n堆栈:" + exception.StackTrace;
    }

    LogUtil.WriteLog(logMsg);
    MessageUtil.ShowError(titleMsg, attachMsg);
}

7.4 开机启动

启动时向注册表写入启动项,保证开机后自动运行。

7.5 客户端数量限制

本程序运行在服务器上,由于端口号的限制,可接入客户端的数量是有上限的。同时,为了防止恶意攻击,有必要限制接入的最大客户端数量,存储为系统配置。

根据项目实际情况,电表数量不超过50台,目前只有13组DTU,因此限制了客户端数量为50。当接入客户端数量达到50个后,新来的连接会被强制关闭。

后期如果加装DTU,可以直接修改上限配置。

八、尾声

本文简要地描述了电表数据采集终端的设计思想,这只是个很简单的数采程序,有关数采核心模块没有过多描述,只贴了部分代码。

作为一个码农,只会用代码表达思想。

程序中还有不合理的地方值得优化。比如网络不通畅,应答延时就会出现串包现象。其实完全可以根据电表地址解析的,但由于总线按照简单的轮询规则收发报文,串包时就会认为解析失败。现在的做法是修改收发间隔来缓解此问题。

项目结束快两年了,运行地挺正常,没必要再花时间优化。

  • 8
    点赞
  • 51
    收藏
    觉得还不错? 一键收藏
  • 13
    评论
### 回答1: HJ/T 212-2017测试软件是中国国家标准化委员会发布的一项技术标准,其主要涵盖了测试软件的相关要求和规范。该标准的发布旨在推动测试软件领域的发展和应用,提高测试软件的质量和效率。 HJ/T 212-2017测试软件主要包括以下几个方面的内容。首先,该标准明确了测试软件的基本要求,包括性能、功能、稳定性、安全性等方面。测试软件必须达到这些要求,确保测试结果的准确性和可靠性。 其次,该标准规定了测试软件的开发流程和管理要求。测试软件的开发应按照一定的流程进行,包括需求分析、软件设计、编码、测试和评审等环节,以确保测试软件的质量。此外,还对测试软件的文档管理、版本控制、配置管理等方面提出了要求。 另外,该标准还规定了测试软件的安全保护要求。测试软件可能涉及到对机密信息的处理,因此,必须进行严格的信息安全保护措施,防止数据泄露和恶意攻击。 最后,该标准还包含了测试软件的测试方法和评估标准。为了确保测试软件的质量和性能达到规定要求,需要采用科学的测试方法进行测试,并按照相应的评估标准对测试结果进行评估。 总的来说,HJ/T 212-2017测试软件是一项重要的技术标准,对于测试软件的开发和应用具有指导意义。在测试软件的开发过程中,应严格按照该标准的要求进行操作,以提高测试软件的质量和效率。同时,也需要不断关注该领域的最新发展,及时更新和完善相关标准,以适应不断变化的测试软件需求。 ### 回答2: HJ/T 212-2017是中国国家质检总局发布的关于测试软件的技术要求和测试方法的标准。该标准主要适用于软件测试行业,旨在提高测试软件的可靠性和有效性。 HJ/T 212-2017标准的核心内容主要包括以下几个方面: 1. 技术要求:该标准要求测试软件必须满足一定的技术要求,包括功能性、性能、易用性、可靠性等方面。在测试软件开发和设计过程中,需要按照标准的要求进行相应的技术实现,以确保测试软件的正常运行和准确性。 2. 测试方法:该标准规定了一套完善的测试方法体系,用于指导测试软件的开发和测试活动。这些方法包括需求分析、测试用例设计、测试执行、结果评估等方面,确保测试软件在不同测试阶段能得到全面和有效的测试。 3. 测试报告:标准要求测试软件必须生成相应的测试报告,其中包含测试软件的基本信息、测试方法和结果等。测试报告能够提供测试过程的完整记录,为后续改进和验证提供依据。 通过遵循HJ/T 212-2017标准的要求,测试软件开发和使用方能够具备一套科学规范的测试流程,提高测试的准确性和可靠性,为相关行业提供准确、可靠的测试结果,同时也保障了产品质量和安全性。这个标准的实施有利于推动软件测试行业的发展和规范,提高测试软件的专业水平和效能。 ### 回答3: HJ/T 212-2017是中国国家环境质量标准规定的一项测试软件。这个软件是用于环境质量监测设备的测试和评价的,主要用于对环境空气、水质和噪声等方面进行检测。该软件的开发旨在提供一种便捷和统一的方法,用于测算和评估监测设备在实际使用中的准确性和可靠性。 HJ/T 212-2017测试软件的主要功能包括设备标定、测量、数据处理和结果分析等。通过使用该软件,用户可以对环境监测设备进行自动化测试,并获得准确和可靠的测试结果。该软件支持各种不同类型的环境监测设备,包括气体分析仪、水质分析仪和噪声仪器等。 在使用HJ/T 212-2017测试软件之前,用户需要先将监测设备连接到计算机上,并根据软件提供的操作指南进行设置和标定。接下来,用户可以选择所需的测试方法和参数,并进行测试。测试过程中,软件将实时监控和记录测试数据,并提供数据处理和结果分析功能。用户可以根据需要对数据进行统计和分析,并生成相应的测试报告。 总的来说,HJ/T 212-2017测试软件是一款功能全面、方便易用的环境质量监测设备测试工具。它不仅能够提高测试的准确性与可靠性,还可以节省人力资源和时间成本。通过使用该软件,监测设备的维护和管理工作将变得更加简单和高效,有助于保障环境监测数据的准确性和可靠性,从而进一步提升环境质量监测的水平。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值