DB_GPT 源码分析(二)

概要

前些日子,我在博客上写了关于db_gpt中有关excel对话的内容,那是我的初次梳理,感觉还是有点生硬,在随着我的不断跟进,又有了新的体会和感受,在看之前梳理的博客,感觉对于研究源码来说还是不太友好,所以我又再次梳理了一次有关这个部分的内容,希望这次可以帮到大家。

当然如果有需要看与数据库对话的宝子们可以看我之前写的博客chat_with_db

因为我个人觉得这个基于excel对话的功能还是挺不错得,对于模型的要求也不算高,这里我用的是Qwen2.5的32B,特别有应用价值,所以想在这里分享给大家。

功能介绍

dbgpt_excel

针对于chat_excel部分,可以分为两块内容

在看了上面的视频之后,我们可以发现对于excel对话内容其实是分为两块内容:

  1. excel文件上传
  2. 基于上传的文件进行问答

excel文件上传

@router.post("/v1/chat/mode/params/file/load")
async def params_load(
    conv_uid: str,  # 会话唯一标识
    chat_mode: str,  # 聊天模式
    model_name: str,  # 模型名称
    user_name: Optional[str] = None,  # 用户名称(可选)
    sys_code: Optional[str] = None,  # 系统代码(可选)
    doc_file: UploadFile = File(...),  # 上传的文件
):
    print(f"params_load: {conv_uid},{chat_mode},{model_name}")
    try:
        if doc_file:
            # 保存上传的文件
            upload_dir = os.path.join(KNOWLEDGE_UPLOAD_ROOT_PATH, chat_mode)  # 构建上传目录路径
            os.makedirs(upload_dir, exist_ok=True)  # 创建目录(如果不存在)
            upload_path = os.path.join(upload_dir, doc_file.filename)  # 构建文件上传路径
            print('文件上传地址' + upload_path)
            async with aiofiles.open(upload_path, "wb") as f:  # 以二进制写入模式打开文件
                await f.write(await doc_file.read())  # 异步写入上传的文件内容

            # 准备聊天会话
            dialogue = ConversationVo(
                conv_uid=conv_uid,  # 会话唯一标识
                chat_mode=chat_mode,  # 聊天模式
                select_param=doc_file.filename,  # 选择的文件名
                model_name=model_name,  # 模型名称
                user_name=user_name,  # 用户名称
                sys_code=sys_code,  # 系统代码
            )
            # 创建聊天实例并准备聊天
            chat: BaseChat = await get_chat_instance(dialogue)
            resp = await chat.prepare()  # 准备聊天内容

            print('-------------------------')
            rres = Result.succ(get_hist_messages(conv_uid))  # 获取会话历史消息
            print(rres)
        # 刷新消息
        return rres
    except Exception as e:
        logger.error("excel load error!", e)  # 记录错误日志
        return Result.failed(code="E000X", msg=f"File Load Error {str(e)}")  # 返回失败信息

# 有excel_reader参数,读取了excel的内容
chat: BaseChat = await get_chat_instance(dialogue)

对于这块内容我们需要alt+左键点击进去再看get_chat_instance方法

async def get_chat_instance(dialogue: ConversationVo = Body()) -> BaseChat:
    logger.info(f"get_chat_instance:{dialogue}")  # 记录获取聊天实例的日志
    if not dialogue.chat_mode:
        dialogue.chat_mode = ChatScene.ChatNormal.value()  # 如果没有聊天模式,默认设置为正常模式
    if not dialogue.conv_uid:
        # 如果没有会话唯一标识,创建一个新的会话
        conv_vo = __new_conversation(
            dialogue.chat_mode, dialogue.user_name, dialogue.sys_code
        )
        dialogue.conv_uid = conv_vo.conv_uid  # 将新会话的唯一标识赋值给对话对象

    # 验证聊天模式是否有效
    if not ChatScene.is_valid_mode(dialogue.chat_mode):
        raise StopAsyncIteration(f"Unsupported Chat Mode,{dialogue.chat_mode}!")  # 如果无效,抛出异常

    # 准备聊天参数
    chat_param = {
        "chat_session_id": dialogue.conv_uid,  # 会话唯一标识
        "user_name": dialogue.user_name,  # 用户名称
        "sys_code": dialogue.sys_code,  # 系统代码
        "current_user_input": dialogue.user_input,  # 当前用户输入
        "select_param": dialogue.select_param,  # 选择的参数
        "model_name": dialogue.model_name,  # 模型名称
    }
    
    # 异步调用阻塞函数以获取聊天实现
    chat: BaseChat = await blocking_func_to_async(
        get_executor(),  # 获取执行器
        CHAT_FACTORY.get_implementation,  # 获取聊天实现的方法
        dialogue.chat_mode,  # 聊天模式
        **{"chat_param": chat_param},  # 聊天参数
    )
    return chat  # 返回聊天实例

其中CHAT_FACTORY.get_implementation, 是获取chat实例的方法

class ChatFactory(metaclass=Singleton):
    @staticmethod
    def get_implementation(chat_mode, **kwargs):
        # 懒加载(Lazy loading)
        from dbgpt.app.scene.chat_dashboard.chat import ChatDashboard  # 导入聊天仪表盘类
        from dbgpt.app.scene.chat_dashboard.prompt import prompt  # 导入提示类
        from dbgpt.app.scene.chat_data.chat_excel.excel_analyze.chat import ChatExcel  # 导入Excel聊天类
        ...
        
        # 获取所有的聊天类(从BaseChat继承的类)
        chat_classes = BaseChat.__subclasses__()
        implementation = None  # 初始化实现变量
        for cls in chat_classes:
            # 检查当前类的聊天场景是否与请求的聊天模式匹配
            if cls.chat_scene == chat_mode:
                metadata = {"cls": str(cls)}  # 准备元数据以供追踪
                with root_tracer.start_span(
                    "get_implementation_of_chat", metadata=metadata  # 开始追踪
                ):
                    implementation = cls(**kwargs)  # 创建匹配类的实例
        if implementation is None:
            # 如果没有找到合适的实现,抛出异常
            raise Exception(f"Invalid implementation name:{chat_mode}")
        return implementation  # 返回找到的聊天实现

在implementation = cls(**kwargs)代码中,当根据chat_mode拿到对应的聊天的对象时候,自动会执行该类下的init方法,这里是拿到了ChatExcel类

class ChatExcel(BaseChat):
    """一个用于分析Excel数据的类"""

    chat_scene: str = ChatScene.ChatExcel.value()  # 聊天场景类型
    keep_start_rounds = 1  # 保留的起始轮次
    keep_end_rounds = 2    # 保留的结束轮次

    def __init__(self, chat_param: Dict):
        """初始化Chat Excel模块
        Args:
           - chat_param: Dict
            - chat_session_id: (str) 聊天会话ID
            - current_user_input: (str) 当前用户输入
            - model_name: (str) LLM模型名称
            - select_param: (str) 文件路径
        """
        chat_mode = ChatScene.ChatExcel  # 设置聊天模式为Excel模式

        # 从chat_param中获取必要的参数
        self.select_param = chat_param["select_param"]  # 选择的参数(文件路径)
        self.model_name = chat_param["model_name"]      # 模型名称
        chat_param["chat_mode"] = ChatScene.ChatExcel  # 更新chat_param中的聊天模式

        # 检查选择的文件路径是否有效
        if has_path(self.select_param):
            # 如果路径有效,使用该路径创建Excel读取器
            self.excel_reader = ExcelReader(self.select_param)
        else:
            # 如果路径无效,构建完整路径并创建Excel读取器
            self.excel_reader = ExcelReader(
                os.path.join(
                    KNOWLEDGE_UPLOAD_ROOT_PATH, chat_mode.value(), self.select_param
                )
            )
        
        self.api_call = ApiCall()  # 初始化API调用对象
        super().__init__(chat_param=chat_param)  # 调用父类构造函数

在这里同样的ExcelReader(self.select_param)在创建ExcelReader对象的时候,会自动执行他的init方法,来读取excel的内容并存入chat的excel_reader属性中

class ExcelReader:
    def __init__(self, file_path):
        # 获取文件名
        file_name = os.path.basename(file_path)  # 从文件路径中提取文件名
        self.file_name_without_extension = os.path.splitext(file_name)[0]  # 获取不带扩展名的文件名
        encoding, confidence = detect_encoding(file_path)  # 检测文件编码
        logger.info(f"Detected Encoding: {encoding} (Confidence: {confidence})")  # 记录编码信息
        self.excel_file_name = file_name  # 保存完整文件名
        self.extension = os.path.splitext(file_name)[1]  # 获取文件扩展名

        # 读取Excel文件
        if file_path.endswith(".xlsx") or file_path.endswith(".xls"):
            # 如果是Excel文件,读取数据
            df_tmp = pd.read_excel(file_path, index_col=False)  # 先读取一次以获取列数
            self.df = pd.read_excel(
                file_path,
                index_col=False,
                converters={i: csv_colunm_foramt for i in range(df_tmp.shape[1])},  # 设置转换器
            )
        elif file_path.endswith(".csv"):
            # 如果是CSV文件,读取数据
            df_tmp = pd.read_csv(file_path, index_col=False, encoding=encoding)  # 先读取一次以获取列数
            self.df = pd.read_csv(
                file_path,
                index_col=False,
                encoding=encoding,
                # csv_colunm_foramt 可以修改更多,只是针对美元人民币符号,假如是“你好¥¥¥”则会报错!
                converters={i: csv_colunm_foramt for i in range(df_tmp.shape[1])},  # 设置转换器
            )
        else:
            raise ValueError("Unsupported file format.")  # 抛出不支持的文件格式异常

        # 将空字符串替换为NaN
        self.df.replace("", np.nan, inplace=True)

        # 删除所有内容为空的“Unnamed”列
        unnamed_columns_tmp = [
            col
            for col in df_tmp.columns
            if col.startswith("Unnamed") and df_tmp[col].isnull().all()
        ]
        df_tmp.drop(columns=unnamed_columns_tmp, inplace=True)  # 删除这些列

        # 仅保留验证后的列
        self.df = self.df[df_tmp.columns.values]

        self.columns_map = {}  # 初始化列映射字典
        for column_name in df_tmp.columns:
            self.df[column_name] = self.df[column_name].astype(str)  # 将列转换为字符串类型
            self.columns_map.update({column_name: excel_colunm_format(column_name)})  # 更新列映射
            try:
                # 尝试将列转换为日期格式
                self.df[column_name] = pd.to_datetime(self.df[column_name]).dt.strftime(
                    "%Y-%m-%d"
                )
            except ValueError:
                try:
                    # 尝试将列转换为数值类型
                    self.df[column_name] = pd.to_numeric(self.df[column_name])
                except ValueError:
                    try:
                        # 如果失败,将列保持为字符串类型
                        self.df[column_name] = self.df[column_name].astype(str)
                    except Exception:
                        print("Can't transform column: " + column_name)  # 打印无法转换的列名

        # 清理列名,去除空格并替换为下划线
        self.df = self.df.rename(columns=lambda x: x.strip().replace(" ", "_"))

        # 连接DuckDB
        self.db = duckdb.connect(database=":memory:", read_only=False)  # 创建内存中的DuckDB连接

        self.table_name = "excel_data"  # 设置表名
        # 在DuckDB中写入数据
        self.db.register(self.table_name, self.df)

        # 获取结果并打印表结构信息
        result = self.db.execute(f"DESCRIBE {self.table_name}")  # 描述表结构
        columns = result.fetchall()  # 获取所有列信息
        for column in columns:
            print(column)  # 打印每列的信息

此时在我们的聊天对象实例中就有了excel的内容了,并且根据chat_model=chat_excel也确实是在聊天实例chat中添加了模板,过程如下

在class ChatFactory(metaclass=Singleton):中根据聊天场景匹配聊天实例的时候implementation = cls(**kwargs) # 创建匹配类的实例,这里是创建的ChatExcel对象,在其init初始化方法中调用了父类的init方法super().init(chat_param=chat_param)
在其父类中的初始化方法中有这么一段代码,根据聊天场景选择提示词模板

self.prompt_template: AppScenePromptTemplateAdapter = (
            CFG.prompt_template_registry.get_prompt_template(
                self.chat_mode.value(),
                language=CFG.LANGUAGE,
                model_name=self.llm_model,
                proxyllm_backend=CFG.PROXYLLM_BACKEND,
            )
        )

之后再执行这段代码的时候

resp = await chat.prepare(),debug进入的是ChatExcel对象的prepare方法

async def prepare(self):
    logger.info(f"{self.chat_mode} prepare start!")  # 记录准备开始的日志
    if self.has_history_messages():  # 检查是否有历史消息
        return None  # 如果有历史消息,直接返回None

    # 准备聊天参数
    chat_param = {
        "chat_session_id": self.chat_session_id,  # 当前聊天会话ID
        "user_input": "[" + self.excel_reader.excel_file_name + "]" + " Analyze!",  # 用户输入,包含Excel文件名
        "parent_mode": self.chat_mode,  # 当前聊天模式
        "select_param": self.excel_reader.excel_file_name,  # 选择的参数(Excel文件名)
        "excel_reader": self.excel_reader,  # Excel阅读器实例
        "model_name": self.model_name,  # 模型名称
    }

    # 创建Excel学习实例
    learn_chat = ExcelLearning(**chat_param)  
    result = await learn_chat.nostream_call()  # 异步调用学习方法
    return result  # 返回结果

learn_chat = ExcelLearning(**chat_param) 在创建ExcelLearning对象时候

class ExcelLearning(BaseChat):
    chat_scene: str = ChatScene.ExcelLearning.value()

    def __init__(
        self,
        chat_session_id,
        user_input,
        parent_mode: Any = None,
        select_param: str = None,
        excel_reader: Any = None,
        model_name: str = None,
    ):
        chat_mode = ChatScene.ExcelLearning
        """ """
        self.excel_file_path = select_param
        self.excel_reader = excel_reader
        chat_param = {
            "chat_mode": chat_mode,
            "chat_session_id": chat_session_id,
            "current_user_input": user_input,
            "select_param": select_param,
            "model_name": model_name,
        }
        super().__init__(chat_param=chat_param)
        if parent_mode:
            self.current_message.chat_mode = parent_mode.value()

可以看到有如下的两行代码

chat_mode = ChatScene.ExcelLearning,
super().init(chat_param=chat_param)

重置了chat_model,调用了父类的构造方法,此时加载的提示词模板是

_DEFAULT_TEMPLATE_ZH = """
下面是用户文件{file_name}的一部分数据,请学习理解该数据的结构和内容,按要求输出解析结果:
    {data_example}
分析各列数据的含义和作用,并对专业术语进行简单明了的解释, 如果是时间类型请给出时间格式类似:yyyy-MM-dd HH:MM:ss.
将列名作为属性名,分析解释作为属性值,组成json数组,并输出在返回json内容的ColumnAnalysis属性中.
请不要修改或者翻译列名,确保和给出数据列名一致.
针对数据从不同维度提供一些有用的分析思路给用户。

请一步一步思考,确保只以JSON格式回答,具体格式如下:
    {response}
"""

result = await learn_chat.nostream_call()
然后才调用的模型,所以最后用到的提示词是上面的这个,之前的excel_analyze中的提示词模板其实是没有用到的。

async def nostream_call(self):
    # 构建模型请求的有效负载
    payload = await self._build_model_request()
    
    # 开始追踪
    span = root_tracer.start_span(
        "BaseChat.nostream_call", metadata=payload.to_dict()  # 记录请求的元数据
    )
    logger.info(f"Request: \n{payload}")  # 记录请求的有效负载信息
    payload.span_id = span.span_id  # 将span_id添加到有效负载中

    try:
        # 进行无流式调用,并重试
        ai_response_text, view_message = await self._no_streaming_call_with_retry(payload)
        
        # 添加AI返回的消息和视图消息到当前消息中
        self.current_message.add_ai_message(ai_response_text)
        self.current_message.add_view_message(view_message)
        
        # 调整消息
        self.message_adjust()
        span.end()  # 结束追踪
    except BaseAppException as e:
        # 如果捕获到应用程序异常,记录视图消息
        self.current_message.add_view_message(e.view)
        span.end(metadata={"error": str(e)})  # 结束追踪并记录错误信息
    except Exception as e:
        # 捕获其他异常,创建错误视图消息
        view_message = f"<span style='color:red'>ERROR!</span> {str(e)}"
        self.current_message.add_view_message(view_message)
        span.end(metadata={"error": str(e)})  # 结束追踪并记录错误信息

    # 存储当前会话
    await blocking_func_to_async(
        self._executor, self.current_message.end_current_round
    )
    
    # 获取当前AI响应
    rest = self.current_ai_response()
    return rest  # 返回响应

async def _no_streaming_call_with_retry(self, payload):
    with root_tracer.start_span("BaseChat.invoke_worker_manager.generate"):
        model_output = await self.call_llm_operator(payload)  # 调用 LLM 操作符,发送 payload 并接收模型输出

    ai_response_text = self.prompt_template.output_parser.parse_model_nostream_resp(
        model_output, self.prompt_template.sep  # 解析模型输出,生成 AI 响应文本
    )
    prompt_define_response = (
        self.prompt_template.output_parser.parse_prompt_response(ai_response_text)  # 解析生成的 AI 响应文本
    )
    metadata = {
        "model_output": model_output.to_dict(),  # 将模型输出转为字典形式保存
        "ai_response_text": ai_response_text,  # 保存 AI 响应文本
        "prompt_define_response": self._parse_prompt_define_response(
            prompt_define_response  # 解析提示定义的响应内容
        ),
    }
    print('base_chat.py--->' + metadata)  # 打印调试信息

    with root_tracer.start_span("BaseChat.do_action", metadata=metadata):
        result = await blocking_func_to_async(
            self._executor, self.do_action, prompt_define_response  # 执行指定的操作
        )

    speak_to_user = self.get_llm_speak(prompt_define_response)  # 获取用于与用户对话的文本

    view_message = await blocking_func_to_async(
        self._executor,
        self.prompt_template.output_parser.parse_view_response,
        speak_to_user,
        result,
        prompt_define_response,
    )
    return ai_response_text, view_message.replace("\n", "\\n")  # 返回 AI 响应和格式化的视图消息

chat with excel

直接进对话框
@router.post("/v1/chat/completions")
async def chat_completions(
    dialogue: ConversationVo = Body(),
    flow_service: FlowService = Depends(get_chat_flow),
):

    #这里是选择的进行流式输出
return StreamingResponse(
                stream_generator(chat, dialogue.incremental, dialogue.model_name),
                headers=headers,
                media_type="text/plain",
            )  
async def stream_generator(chat, incremental: bool, model_name: str):
    """
    生成流式响应。

    目标是生成兼容 OpenAI 的流式响应。
    当前增量响应是兼容的,未来将对完整响应进行转换。

    参数:
        chat (BaseChat): 聊天实例。
        incremental (bool): 用于控制内容是增量返回还是每次返回完整响应。
        model_name (str): 模型名称。

    产出:
        流式响应。
    """
    span = root_tracer.start_span("stream_generator")  # 开启追踪 span,记录执行时间等元数据
    msg = "[LLM_ERROR]: llm server has no output, maybe your prompt template is wrong."  # 错误信息的初始值

    previous_response = ""  # 记录上一次的响应内容,用于计算增量输出
    async for chunk in chat.stream_call():  # 异步调用 chat 的 stream_call 方法,逐块获取模型响应
        if chunk:  # 如果当前块有内容
            msg = chunk.replace("\ufffd", "")  # 移除可能的无效字符 (如 `\ufffd`)
            if incremental:  # 如果使用增量模式
                incremental_output = msg[len(previous_response) :]  # 计算本次增量内容
                choice_data = ChatCompletionResponseStreamChoice(
                    index=0,
                    delta=DeltaMessage(role="assistant", content=incremental_output),  # 将增量内容设置为响应体
                )
                chunk = ChatCompletionStreamResponse(
                    id=chat.chat_session_id, choices=[choice_data], model=model_name  # 创建兼容 OpenAI 的流式响应
                )
                json_chunk = model_to_json(
                    chunk, exclude_unset=True, ensure_ascii=False  # 将响应序列化为 JSON 格式
                )
                yield f"data: {json_chunk}\n\n"  # 将增量响应作为流式输出的一部分返回
            else:
                # TODO: 生成一个兼容 OpenAI 的完整流式响应
                msg = msg.replace("\n", "\\n")  # 处理换行符以确保格式兼容
                yield f"data:{msg}\n\n"  # 返回完整响应数据
            previous_response = msg  # 更新上一次的响应内容为当前响应
            await asyncio.sleep(0.02)  # 添加短暂延迟,模拟实时流式输出
    if incremental:  # 如果是增量模式,最后返回 `[DONE]` 表示流结束
        yield "data: [DONE]\n\n"
    span.end()  # 结束追踪 span
    
"""
功能:
	stream_generator 方法生成流式响应,适用于兼容 OpenAI 的增量返回模式。在增量模式下,每次仅返回与上次不同的新增内容;否则将返回整个响应。它通过异步生成器逐步返回数据,模拟实时响应的效果。
重要部分:
	增量模式计算新内容并返回。
	async for 循环用于逐块读取模型响应。
	使用短延迟来模拟连续的流式数据传输。
"""

# 这个方法是 BaseChat 类中的方法,它负责向 LLM(大语言模型)发送请求并处理返回的流式响应。
async def stream_call(self):
    # TODO: 在服务器连接错误时重试
    payload = await self._build_model_request()  # 构建模型请求的 payload

    logger.info(f"payload request: \n{payload}")  # 记录请求日志信息
    ai_response_text = ""  # 初始化 AI 响应文本为空
    span = root_tracer.start_span(
        "BaseChat.stream_call", metadata=payload.to_dict()  # 开启 span,追踪请求的元数据
    )
    payload.span_id = span.span_id  # 将 span ID 赋值给请求的 payload
    try:
        async for output in self.call_streaming_operator(payload):  # 异步调用流式操作符,逐块接收模型输出
            # 结果生成时调用插件(如某种处理逻辑)
            msg = self.prompt_template.output_parser.parse_model_stream_resp_ex(
                output, 0  # 解析模型返回的流式响应
            )
            # 给出答案
            view_msg = self.stream_plugin_call(msg)  # 调用插件,处理响应内容
            view_msg = view_msg.replace("\n", "\\n")  # 将换行符替换为 "\\n" 以符合输出格式
            yield view_msg  # 逐块返回处理过的视图消息
        self.current_message.add_ai_message(msg)  # 将模型生成的响应文本添加到当前消息对象
        view_msg = self.stream_call_reinforce_fn(view_msg)  # 执行进一步的流式响应强化
        self.current_message.add_view_message(view_msg)  # 将最终生成的视图消息添加到当前消息对象
        span.end()  # 结束 span 追踪
    except Exception as e:  # 捕获异常
        print(traceback.format_exc())  # 打印异常堆栈信息
        logger.error("model response parse failed!" + str(e))  # 记录错误日志
        self.current_message.add_view_message(
            f"""<span style=\"color:red\">ERROR!</span>{str(e)}\n  {ai_response_text} """  # 展示错误信息
        )
        ### 存储当前对话
        span.end(metadata={"error": str(e)})  # 在 span 中记录错误元数据
    await blocking_func_to_async(
        self._executor, self.current_message.end_current_round  # 异步执行,结束当前消息轮次
    )
"""
 功能:stream_call 方法负责向 LLM 发送请求并异步接收流式响应。
        响应会通过插件进行处理,并以符合格式的视图消息形式返回。
        该方法还包含异常处理逻辑以应对可能的解析或网络错误。
重要部分:
    call_streaming_operator 方法用于异步接收流式数据。
    插件处理 stream_plugin_call 和强化 stream_call_reinforce_fn。
    详细的异常处理,捕获并展示错误信息。
    使用追踪 span 监控性能。
"""
# 这个方法负责真正与模型的流式接口进行通信,发送请求并接收流式输出。
async def call_streaming_operator(self, request: ModelRequest) -> AsyncIterator[ModelOutput]:
    llm_task = build_cached_chat_operator(self.llm_client, True, CFG.SYSTEM_APP)  # 创建缓存的聊天操作符
    async for out in await llm_task.call_stream(call_data=request):  # 异步调用流式接口,发送请求并逐块接收输出
        yield out  # 逐块返回模型输出
"""
功能:call_streaming_operator 方法负责调用底层的语言模型接口,通过流式方式返回响应。它利用了异步生成器,将每一块数据逐步返回给调用者。
重要部分:
build_cached_chat_operator 创建了一个缓存的聊天操作符,用于减少重复请求的开销。
使用 async for 异步迭代器来逐步接收 LLM 的流式输出。
每次接收到模型输出时,使用 yield 返回给调用方,保证了响应的流式特性。
"""
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值