结对项目-最长英语单词链

结对项目-最长英语单词链

项目内容
这个作业属于哪个课程2023年北航敏捷软件工程社区-CSDN社区云
这个作业的要求在哪里结对项目-最长英语单词链-CSDN社区
我在这个课程的目标是了解C++项目构造方法,使用单元测试工具开发高鲁棒应用程序
这个作业在哪个具体方面帮助我实现目标对软件实例有初步分析和认知,学习结对编程方法
1.在文章开头给出教学班级和可克隆的 Github 项目地址。
  • 教学班级:周四班
  • 项目地址:https://github.com/Kazeya27/buaa-ase_WordChain.git
2.在开始实现程序之前,在下述 PSP 表格记录下你估计将在程序的 各个模块的开发上耗费的时间。
PSPPersonal Software Process Stags预估耗时(分钟)实际耗时(分钟)
Planning计划3030
·Estimate·估计这个任务需要多少时间3030
Development开发17002280
·Analysis·需求分析(包括学习新技术)60120
·Design Spec·生成设计文档3060
·Design Review·设计复审(和同事审核设计文档)3030
·Coding Standard·代码规范(为目前的开发制定合适的规范)2030
·Design·具体设计120180
·Coding·具体编码9601260
·Code Review·代码复审120180
·Test·测试(自我测试,修改代码,提交修改)360420
Report报告160160
·Test Report·测试报告120120
·Size Measurement·计算工作量2020
·Postmortem & Process Improvement Plan·事后总结,并提出改进计划2020
-合计18902470
3.看教科书和其它资料中关于 Information Hiding,Interface Design,Loose Coupling 的章节,说明你们在结对编程中是如何利用这些方法对接口进行设计的。
  • 信息隐藏(Information Hiding):信息隐藏指在设计和确定模块时,使得一个模块内包含的特定信息(过程或数据),对于不需要这些信息的其他模块来说,是不可访问的。

    我们对整体模块进行划分,core模块仅对外暴露三个需要调用的接口,外部不需要知道的数据和过程(如图数据和建图过程)则不允许使用者调用。

  • 接口设计(Interface Design):接口作为软件系统不同部分衔接的约定,可以使系统功能得到合理划分。

    我们在设计时明确了输入、输出、参数解析、建图等模块的参数以及返回值含义,避免在编码过程中因为某些接口存在歧义而导致错误。

  • 松耦合(Loose Coupling):松耦合是指降低模块之间的关联程度,避免修改一个模块而导致其他模块也需要随之改变。

    在我们设计中,将整个系统拆分为输入、核心计算、输出模块,并通过设计接口规定了三个模块之间的交流方式,降低模块之间的依赖性。

4.计算模块接口的设计与实现过程。设计包括代码如何组织,比如会有几个类,几个函数,他们之间关系如何,关键函数是否需要画出流程图?说明你的算法的关键(不必列出源代码),以及独到之处。

题目要求为求最长单词链,可以将单词看作点,能连在一起的单词看作边,得到一张图,问题转化为求图的最长路。共有3个主要函数,分别处理DAG中最长路,DAG中所有路径,一般图中的最长路。对于DAG中的最长路,采用DP的思路,维护当前点结束的最长路径长度,同时记录从那个点转移来的。对于后面两种情况,采用DFS枚举所有情况。在求解时带入了参数(首字母,尾字母等),避免了大量的分类讨论。

5.展示在所在开发环境下编译器编译通过无警告的截图

通过CMake构建命令行可执行文件成功且无警告。

在这里插入图片描述

通过CMake打包core.dll成功且无警告。
在这里插入图片描述

gui运行成功且无警告。
在这里插入图片描述

6.阅读有关 UML 的内容,画出 UML 图显示计算模块部分各个实体之间的关系(画一个图即可)。

在这里插入图片描述

7.**计算模块接口部分的性能改进。**记录在改进计算模块性能上所花费的时间,并展示你程序中消耗最大的函数,陈述你的性能改进策略。

我们项目中主要对-r参数的情况进行优化,在存在大量单词环时,搜索将占用大量CPU时间。使用测试数据如下:

aa axa aya aza

ab aab

ac axc ayc azc

ba bxa bb bxb bc bxc

ca cxa cb cxb cc cxc cyc czc cd

使用Visual Studio性能探查器分析如下图所示,计算总时间为31.626秒。可见circle_max函数消耗最大。

在这里插入图片描述

对于存在单词环的情况,问题会转化为求一般图中的最长路(每个点只能经过一次)。此问题为NP问题,需要使用指数级别的搜索来解决。此时可以对图进行一定的优化以降低搜索的次数。具体方法为:对于所有首字母和尾字母相同的单词,不再两两之间连边,而是只连出一个环,同时选择一个点连所有的入边,然后环中它的上一个点连所有的出边。这样在搜索时就可以搜到一个单词后,首先链接所有首尾字母相同的单词(例如ab-bxb-byb-bzb-bc)。

优化后,同样的数据计算总时间为12.911秒,同时可以看到circle_max函数所消耗资源大大降低。

在这里插入图片描述

8.阅读 Design by Contract,Code Contract 的内容,并描述这些做法的优缺点,说明你是如何把它们融入结对作业中的。
Design by Contract
  • 定义

    • 契约式编程规定软件设计者应该为软件组件定义正式的、精确的和可验证的接口规范,为传统的抽象数据类型又增加了先验条件、后验条件和不变式。
  • 优点

    • 提高系统运行可靠性。
    • 由于前置条件和后置条件的设计,出现错误时能够及时精准地定位,节省测试时间。
  • 缺点

    • 设计良好的契约需要一定量的训练和时间。
    • 复杂的规则和条件,可能导致程序存在大量冗余。
  • 应用

    • 我们结对编程尽管没有采用形式化语言描述接口,但还是尽量为接口增加了先验条件和后置条件。

      比如core模块的各个函数,在调用之前会经过大量异常情况判断(如-h参数后不是字母),即确保参数异常时决不调用函数。同时,明确这些函数完成后各返回值意义,即保证其后置条件。

Code Contract
  • 是微软推出的一款用于契约式设计的插件
  • 优点
    • 提供一套规划的契约式设计框架,如自动测试工具、静态合约验证等。
  • 结对过程中没有使用该插件,仅借鉴其思想,确保一些函数调用的先验条件和后置条件。
9.计算模块部分单元测试展示。展示出项目部分单元测试代码,并说明测试的函数,构造测试数据的思路。并将单元测试得到的测试覆盖率截图,发表在博客中。要求总体覆盖率到 90% 以上,否则单元测试部分视作无效。

单元测试使用Google Test测试框架,一部分手工构造样例以求覆盖所有参数和异常情况,保证单元测试覆盖率,一部分使用随机生成数据保证功能正确性。

本部分主要对core暴露的接口以及cli解析参数进行了测试,尽可能全面覆盖所有参数情况,对返回值进行正确性检验,对单词列表进行合法性检验。

  • void answer_test(int index, char*words[], char*result[], int* rtn)读取输入参数以及测试数据,调用对应接口,并检验返回值是否正确。

    void answer_test(int index, char*words[], char*result[], int* rtn) {
        string fileName = "../test/CoreTests/testfile" + to_string(index) + ".txt";
        ifstream ifile;
        ifile.open(fileName,ios::in);
        string line;
        vector<char*> buffer;
        while (getline(ifile, line))
        {
            // 读取测试参数和数据
        }
    	// 调用对应接口
        switch (model) {
            case 0:
                tmp = gen_chains_all(words,len,result);
                break;
            case 1:
                // 调用对应接口
                ......
        }
        *rtn = tmp;
        // 返回值正确性检验
        ASSERT_EQ(tmp,realRtn);
        if (model == 0) {
            // 检验-n参数下,返回的单词链是否满足首尾相接
            chain_test_all(result,*rtn);
        }
        else {
            // 检验-w,-c参数下,返回的单词链是否首尾相接
            // 是否满足-h,-t,-j三个参数要求
            chain_test(result,*rtn);
            head_tail_ban_test(result,head,tail,banned,*rtn);
        }
    }
    
  • void chain_test_all(char*result[], int len)以及void chain_test(char*result[], int len)检查计算所得单词链是否满足首尾相接的约束。

    void chain_test_all(char*result[], int len) {
        for (int i = 0;i<len;i++) {
            char* s = result[i];
            int len1 = strlen(s);
            for (int j = 0;j<len1;j++) {
                for (int k = j+1;k<len1;k++) {
                    if (s[k] == ' ') {
                        ASSERT_EQ(s[k-1],s[k+1]);
                        j = k + 1;
                        break;
                    }
                }
            }
        }
    }
    
  • void head_tail_ban_test(char*result[],char head,char tail,char banned,int len) 检查返回的单词链是否满足-h,-t,-j三个参数要求。

    void head_tail_ban_test(char*result[],char head,char tail,char banned,int len) {
        if (len > 0) {
            if (head != '\0')
                ASSERT_EQ(result[0][0],head);
            if (tail != '\0') {
                int str_len = strlen(result[len - 1]);
                ASSERT_EQ(result[len-1][str_len - 1],tail);
            }
            if (banned != '\0') {
                for (int i = 0;i<len;i++) {
                    ASSERT_NE(result[i][0],banned);
                }
            }
        }
    }
    
  • void unique_test(char*result[],int len)检查单词链中的各单词是否满足唯一性要求。

    void unique_test(char*result[],int len) {
        unordered_set<string> unique;
        for (int i = 0;i<len;i++) {
            ASSERT_TRUE(unique.find(result[i]) == unique.end());
            unique.insert(result[i]);
        }
    }
    

在这里插入图片描述

10.计算模块部分异常处理说明。在博客中详细介绍每种异常的设计目标。每种异常都要选择一个单元测试样例发布在博客中,并指明错误对应的场景。

本次项目共设计了10种异常并进行测试。由于该部分单元测试构造思路为读取配置文件直接调用命令行模块的main函数,这里展示调用过程,此后的样例中仅给出配置文件,配置文件第一行表示异常码,第二行表示使用数据文件,之后表示参数。

void cli_test(int index) {
    char* argv[15];
    int argc = 0;
    string fileName = "../test/CliTests/config" + to_string(index) + ".txt";
    ifstream ifile;
    ifile.open(fileName,ios::in);
    string line;
    getline(ifile,line);
    int realRtn = stoi(line);
    getline(ifile,line);
    char*path = new char[line.length() + 20];
    strcpy_s(path,18,"../test/CliTests/");
    path = strcat(path,line.c_str());
    argv[argc++] = (char*)"Wordlist.exe";
    while (getline(ifile, line))
    {
        if (!line.empty())
        {
            char* ptr = new char[line.length() + 1];
            strcpy_s(ptr, line.length() + 1, line.c_str());
            argv[argc++] = ptr;
        }
    }
    argv[argc++] = path;
    int rtn = test_main(argc,argv);
    ASSERT_EQ(rtn, realRtn);
}
  • 参数不兼容(PARAM_CONFLICT)
    • 所给参数不兼容(如-n和-w同时使用),具体参数冲突如下表:

      -n-w-c-h-t-j-r
      -n×××××××
      -w×××
      -c×××
      -h××
      -t××
      -j×x
      -r××
    • 单元测试样例

      -1            // 命令行异常返回-1
      chain.txt     // 使用文件chain.txt(无环数据)
      -n		      // 参数1
      -w			  // 参数2
      			  // -n 与 -w冲突。
      
  • 必要参数缺失(PARAM_LACK)
    • 只出现-h,-t,-j,-r四个参数,而没有使用-n,-w,-c三个必要参数。

    • 单元测试样例

      -2			 	
      chain.txt				
      -j				
      a				
      -h				
      b
      
  • 数据存在单词环(WORD_CIRCLE)
    • 数据中存在单词环,但是并没有给出-r参数。

    • 单元测试样例

      -3
      circle.txt	// 使用数据circle.txt(带环数据)
      -c
      -h a
      
  • 参数格式错误(PARAM_FORMAT)
    • 指定-h,-t,-j三个参数时,其后没有紧跟单个字母(没有字符,字符不是字母,多个字符)。

    • 单元测试样例

      -4
      chain.txt
      -w
      -t
      xx
      
      -4
      chain.txt
      -w
      -h
      ?
      
      -4
      chain.txt
      -w
      -j
      -h
      a
      
  • 使用未定义参数(PARAM_UNDEFINED)
    • 所给参数中出现为定义的参数,即出现除-n,-w,-c,-h,-j,-t,-r外的参数。

    • 单元测试样例

      -5
      chain.txt
      -w
      -g
      
  • 输入非文本文件(FILE_TYPE_WRONG)
    • 指定的文件不是文本文件,即文件格式不是.txt

    • 单元测试样例

      -6
      chain.py
      -w
      
  • 文件不存在(FILE_NOT_EXIST)
    • 指定输入文件不存在。

    • 单元测试样例

      -7
      chainx.txt
      -w
      
  • 未选择文件(FILE_NOT_GIVEN)
    • 参数中不存在文件路径。

    • 单元测试样例

      TEST(TestCase,FileNotSelected){
          char* argv[15];
          int argc = 0;
          argv[argc++] = (char*)"Wordlist.exe";
          argv[argc++] = (char*)"-w";
          argv[argc++] = (char*)"-h";
          argv[argc++] = (char*)"a";
          ASSERT_EQ(test_main(argc,argv), -9);
      }
      
  • 参数重复指定(PARAM_DUPLICATE)
    • 重复指定-h,-t,-j三个参数的字母。

    • 单元测试样例

      -10
      chain.txt
      -w
      -t
      a
      -t
      s
      
  • 答案过长(RESULT_TOO_LONG)
    • 计算结果数量超过20000。

    • 单元测试样例

      bc cc cd de df ee ef eg ff fg gg gh gi hh hi ii ij jk jl jj kk kl ll lm ln mm mn nn
      
11.界面模块的详细设计过程。在博客中详细介绍界面模块是如何设计的,并写一些必要的代码说明解释实现过程。
  • CLI

CLI划分为四个模块:参数解析模块、文件读取模块、函数调用模块、输出模块。

参数解析模块遍历命令行参数(即main函数中的argv),并检测用户输入的参数是否存在问题,及时终止程序并及时反馈给用户。

int read_para(int argc,char *argv[],int* qall)
{
    // 记录-h,-t,-j参数是否已经出现过
    bool flagH = false,flagT = false,flagJ = false; 
    for(int i=1;i<argc;i++)                       // 遍历参数列表
    {
        string ag=argv[i];
        if(ag=="-w")                                // -w参数
        {
            if(checkp(2)<0)                         // 异常检测
                return PARAM_CONFLICT;              // 返回异常码
            arg[2]=1;                               // 记录已有的参数
            op=0;                                   // 记录操作类型,调用对应接口
        }
        else if(ag=="-c")
        {
            if(checkp(3)<0)
                return PARAM_CONFLICT;
            arg[3]=1;
            op=1;
        }
        else if ......
}

文件读取模块读取参数解析后打开的文件,进行单词划分并转换为小写,记录下这些单词单词后返回一个二维向量。

// 读取文件,并划分单词
while(getline(readFile,s))
{
    string now;
    for(int i=0;i<s.size();i++) // 对文件每一行字符进行单词划分
    {
        if(!isletter(s[i]))
        {
            if(now.size()>0)
                res.push_back(now);
            now="";
        }
        else
            now+=(s[i]<'a')?(s[i]+'a'-'A'):s[i];
    }
    if(now.size()>0)
        res.push_back(now);
}

函数调用模块根据前两个模块得到的参数信息已经单词表,调用core模块暴露的接口,并判断接口返回值是否异常(存在单词环或计算结果过长)。

输出模块将函数调用得到的结果,输出到solution.txt文件中。

void print_ans(int len,char* result[],int operate)
{
    // 打开/创建solution.txt文件,输出答案
    ofstream fout("solution.txt");
    if(operate==1)
        fout<<len<<"\n";
    for(int i=0;i<len;i++)
        fout<<result[i]<<"\n";
    fout.close();
}
  • GUI

GUI使用python的pyqt库进行开发。共可划分为六个模块:数据输入模块、参数选择模块、输出模块、接口调用模块、异常窗口模块、主界面模块。

数据输入模块利用qt提供的QTextEditQFileDialog组件,支持用户通过导入文件进行单词输入、通过界面直接输入,该模块记录下单词文本,并提供给接口调用模块进行调用。

# 读取文件
def getFile(self):
    # 指定文件类型为.txt文件
    filename, t = QFileDialog.getOpenFileName(self, "打开文件", '', '文本文件(*.txt)')
    # 防止取消打开文件后闪退
    if filename:
        if not filename.endswith(".txt"):
            WarningView("请选择txt文件输入")
        else:
            self.filePath.setText(filename)
            with open(filename, 'r', encoding='utf-8', errors='ignore') as f:
                data = f.read()
                self.inputBox.setText(data)

# 返回记录的文本
def getInputData(self):
	return self.inputBox.toPlainText()

参数选择模块主要使用qt提供的QRadioButtonQComboBox以及QCheckBox进行编写,记录下用户对所要求7个参数的选择情况。同时通过逻辑判断以及按钮的setEnable方法避免用户选择冲突的参数。

    # 控制额外参数-h,-t,-j,-r是否可选
    def setAdditionEnabled(self, isTrue: bool):
        self.beginCharBox.setEnabled(isTrue)
        self.endCharBox.setEnabled(isTrue)
        self.banCharBox.setEnabled(isTrue)
        self.circleBtn.setEnabled(isTrue)
        if not isTrue:
            self.beginCharBox.setCurrentIndex(0)
            self.endCharBox.setCurrentIndex(0)
            self.banCharBox.setCurrentIndex(0)
	
    # 选择-n参数时,额外参数无法选择
    def clickAllBtn(self):
        self._model = 0
        self.setAdditionEnabled(False)

    # 选择-w或-c参数时,额外参数可供选择  
    def clickWordBtn(self):
        self._model = 1
        self.setAdditionEnabled(True)

    def clickLetterBtn(self):
        self._model = 2
        self.setAdditionEnabled(True)

输出模块同样利用qt的QTextEditQFileDialog组件,展示计算结果,并为用户提供导出结果的功能。

    # 将结果展示到窗口
    def setOutputView(self, output):
        self.outputView.setText(output)

    # 导出计算结果
    def export(self):
        filename, t = QFileDialog.getSaveFileName(self, '导出结果', 'ans', "文本文件(*.txt)")
        if filename:
            with open(filename, 'w') as f:
                f.write(self.outputView.toPlainText())

接口调用模块使用ctypes库导入计算模块的动态链接库,通过ctypes实现的一系列类型转换方法,将python的数据类型包装为C类型作为函数调用参数。以gen_chains_all方法为例:

# 通过WinDLL方法导入动态链接库
dll = WinDLL("../bin/core.dll")

# int gen_chains_all(char* words[], int len, char* result[]);
def gen_chains_all(words):
    length = len(words)
    # 使用c_char_p创建指针数组并通过encode转换为二进制序列
    words_ptr = (c_char_p * length)()
    for i in range(length):
        words_ptr[i] = words[i].encode('utf-8')
    # 使用c_int创建c的int类型
    c_len = c_int(length)
    # 与words同理
    c_rst_ptr = (c_char_p * 20005)()
    # 调用core.dll对外提供的gen_chains_all接口
    cnt = dll.gen_chains_all(words_ptr, c_len, c_rst_ptr)
    # 根据返回结果判断是否有异常
    if cnt > 20000:
        rst = []
        WarningView("结果过长,请检查数据")
    elif cnt >= 0:
        rst = [str(cnt).encode('utf-8')]
        for i in range(1, cnt + 1):
            rst.append(c_rst_ptr[i - 1])
    else:
        rst = []
        WarningView("存在单词环,请检查数据或允许单词环")
    return rst

异常窗口模块使用qt的QMessageBox组件,当其他模块判断出现异常时(如文件类型,文件不存在等),可直接创建一个WarningView对象,及时给用户反馈。

class WarningView(QWidget):
    # msg表示反馈给用户的信息
    def __init__(self, msg):
        super().__init__()
        warn = QMessageBox()
        warn.setText(msg)
        warn.setIcon(QMessageBox.Critical)
        warn.setWindowTitle("异常提示")
        warn.exec_()

主界面模块将各模块进行布局,统一放置在一个界面上,为简化组件间通信问题,在主界面模块额外增加开始计算按钮以调用接口,并记录运行时间。

12.界面模块与计算模块的对接。详细地描述 UI 模块的设计与两个模块的对接,并在博客中截图实现的功能。
  • core模块

    core模块通过__declspec(dllexport)将三个计算接口声明为导出函数,同时由于计算模块代码使用C++编写,为防止函数重载还需要声明extern "C"让函数使用C编译方式。同时为便于gui交换,我们提供的接口与课程作业建议一致。

    由于项目使用CMake构建,为打包成动态链接库,需要在CMakeLists.txt中添加如下配置。

    # 表示使用core.cpp和core.h文件导出dll
    add_library(core SHARED src/core.cpp src/core.h)
    
  • CLI

    CLI通过extern "C" __declspec(dllimport)导入dll的函数,解析参数以及划分单词后在对接部分直接调用对应接口,并对返回值进行检验。

    以下功能演示为部分异常反馈,以及正常使用情况。

在这里插入图片描述

  • GUI

    GUI的设计与对接方法在上文中已经详细描述,通过python的ctypes库实现动态链接库导入以及python数据类型向C数据类型的转换。

    以下为功能展示。

    计算所有单词链(此时禁用额外参数):

在这里插入图片描述

计算单词数量最多的单词链,并指定首字母为m

在这里插入图片描述

输入数据中存在单词环且用户没有允许:
在这里插入图片描述
在这里插入图片描述
计算结果过长:

在这里插入图片描述

将结果以文本文件导出:

在这里插入图片描述

  • 附加任务:交换核心模块与界面模块
    • 合作小组队员学号信息:19375263,20373788

    • 由于合作小组在存在单词环以及计算结果过长两种异常情况下,返回值与我们不相同,在处理异常时需要进行适当修改,修改异常码判断后一切功能正常。

      存在单词环以及计算结果过长异常提示:

    在这里插入图片描述
    在这里插入图片描述

    正常运行结果:

    在这里插入图片描述

13.描述结对的过程,提供两人在讨论的结对图像资料(比如 Live Share 的截图)。关于如何远程进行结对参见作业最后的注意事项。

由于我们宿舍距离近,日常在寝室进行结对编程。

在这里插入图片描述

14.说明结对编程的优点和缺点。同时描述结对的每一个人的优点和缺点在哪里(要列出至少三个优点和一个缺点)。
结对编程优缺点分析
  • 优点
    • 写完的代码能及时交给另一个人复审,减少了测试时的工作量
    • 两个人能及时沟通思路和问题解决方案,避免了有时联系不上对方而耽误工作,同时也减少了对接时消耗的时间。
    • 双方能够互补对方的优势,有的问题自己需要查阅大量资料才能解决,而恰好对方擅长这个工作,就能迅速解答。
    • 编码的专注度更高,意识到有人在监督自己时,会减少摸鱼时间,自己一个人编码可能时间久一点就想看看别的。
  • 缺点
    • 需要双方协调时间,由于课程紧张,双方时间有时难以协调。
    • 实现一些比较简单的功能时,一人监督+复审,一人编码的形式难免会导致效率较低
成员优缺点分析

wyy优缺点

  • 优点

    • 学习新工具、新技术的能力较强。

    • 具有一定项目经验,代码编写规范。

    • 曾经参与开发pyqt相关项目,对此比较熟悉。

    • 阅读需求文档仔细,避免开发时忽略某些要求。

  • 缺点

    • 算法苦手。
    • windows用户,对Linux环境不熟悉。

wzy优缺点

  • 优点
    • 算法巨佬,看到需求就知道应该如何实现。
    • 执行力强,找到bug能够及时修改。
    • 对c++各容器非常了解。
  • 缺点
    • 对工具链使用不熟悉,如git、clion。
    • 代码编写比较不规范。
15.在你实现完程序之后,在附录提供的PSP表格记录下你在程序的各个模块上实际花费的时间。

2.在开始实现程序之前,在下述 PSP 表格记录下你估计将在程序的各个模块的开发上耗费的时间

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值