Springboot整合全文检索引擎Lucene

前言

如何自己构建一个单机的博客检索引擎?

本章代码已分享至Gitee: https://gitee.com/lengcz/springbootlucene01

Lucene的介绍

Lucene是一个开源的全文搜索引擎工具包,它提供了用于创建、索引和搜索大量文本的API和工具。Lucene最初是由Doug Cutting于1999年创建,目的是提供一个高效、可扩展的搜索引擎。

Lucene的主要特点包括:

  1. 高性能:Lucene使用倒排索引来加速搜索,这使得它能够快速地检索和排序文档。

  2. 可扩展性:Lucene支持大规模文本索引和搜索操作,并可以通过水平扩展来处理更大量级的数据。

  3. 多语言支持:Lucene支持多种语言的文本索引和搜索,包括中文。

  4. 高级搜索功能:除了基本的文本搜索,Lucene还提供了许多高级搜索功能,例如模糊搜索、通配符搜索、范围搜索等。

  5. 定制化:Lucene提供了丰富的API和工具,可以根据需求进行定制化开发。

在Lucene中进行中文搜索时,需要注意以下几点:

  1. 分词:中文文本通常不用空格进行分词,因此需要使用中文分词器来对文本进行切分。Lucene提供了一些中文分词器,例如IK Analyzer、SmartChineseAnalyzer等。

  2. 中文编码:Lucene默认使用Unicode编码进行搜索,但是如果要处理中文文本,需要使用UTF-8编码。

  3. 中文排序:中文的排序规则与英文不同,Lucene提供了一些中文排序器来处理中文排序。

总之,Lucene是一个功能强大、灵活可定制的全文搜索引擎工具包,可以用于构建各种类型的中文搜索应用。

springboot项目中如何整合Lucene简单用法

1. 引入依赖

 <!-- Lucene核心库 -->
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-core</artifactId>
            <version>7.6.0</version>
        </dependency>

        <!-- Lucene的查询解析器 -->
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-queryparser</artifactId>
            <version>7.6.0</version>
        </dependency>

        <!-- Lucene的默认分词器库 -->
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-analyzers-common</artifactId>
            <version>7.6.0</version>
        </dependency>

        <!-- Lucene的高亮显示 -->
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-highlighter</artifactId>
            <version>7.6.0</version>
        </dependency>

        <!-- ik分词器 -->
        <dependency>
            <groupId>com.jianggujin</groupId>
            <artifactId>IKAnalyzer-lucene</artifactId>
            <version>8.0.0</version>
        </dependency>

2. 其它用到的类

import lombok.Data;
import lombok.Getter;
import lombok.Setter;

import java.io.Serializable;

@Setter
@Getter
public class BlogTitle implements Serializable {

    private Long id;

    private String title;

    private String description;

    public BlogTitle() {
    }

    public BlogTitle(Long id, String title, String description) {
        this.id = id;
        this.title = title;
        this.description = description;
    }
}

2. 创建索引

 /**
     * 模拟数据源,正常需要读取数据库或者其它数据源内容
     * @return
     */
    private List<BlogTitle> getBlogList() {
        List<BlogTitle> list = new ArrayList<>();
        list.add(new BlogTitle(1000L, "Springboot 零基础入门", "springboot基础(1):IDEA构建Springboot项目"));
        list.add(new BlogTitle(1001L, "springboot基础(1):IDEA构建Springboot项目", "IDEA 构建Springboot项目\n" +
                "第一种、普通方法构建Springboot项目"));
        list.add(new BlogTitle(1002L, "springboot基础(2):IDEA查看当前项目的依赖结构图", "pom文件里,右键>>Diagrams>>Show dependencies"));
        list.add(new BlogTitle(1003L, "Mysql核心教程", "初始化MySQL 在命令提示符窗口中运行mysqld --initialize-insecure,如果没有出现报错,则证明data目录初始化成功。 mysqld --initialize-insecure 1 此时当我们再打开查看MyS..."));
        list.add(new BlogTitle(1004L, "SqlServer2000如何安装", "SQL Server是微软公司开发的数据库产品,SQL Server 2000被广泛使用,很多电子商务网站、企业内部信息化平台等都是基于SQL Server产品上。 今天的商业环境要求不同类型的数据库解决方案。性能、可伸缩性及可靠性是基本要求,而进入市场时间也非常关键。除这些"));
        list.add(new BlogTitle(1005L, "明朝那点事儿", "明朝(1368年―1644年),中国历史上的朝代,由明太祖朱元璋所建。初期建都南京,明成祖时期迁都北京。传十六帝,共计276年。元末爆发红巾军起义,朱元璋加入郭子兴起义军。1364年称吴王。1368年初称帝,国号大明,定都南京。1421年朱棣迁都北京,以南京为陪.."));
        list.add(new BlogTitle(1006L, "厨房日记", "不过,我没做过饭呀!对了,找妈妈帮忙。我找来妈妈,一起走进厨房。我想做西红柿炒鸡蛋,便从冰箱里拿出两个鸡蛋、两个西红柿,又从柜子里取出一个碗和一个盘子。做菜的..."));
        list.add(new BlogTitle(1007L, "一往无前", "从底层技术自研到全场景覆盖,重估小米新十年|雷军|苹果|小...\n" +
                "2024年7月23日 2020年,雷军在小米集团成立十周年的演讲上,为小米定下“技术为本、性价比为纲、做最酷的产品”三大铁律。随后的三年,尽管小米短暂陷入低谷,但集团上下的动作未.."));
        list.add(new BlogTitle(1008L, "百草园", "著名武术巨星马保国喜欢吃苹果,西瓜,葡萄,热爱传扬武术,具有良好的武德"));
        return list;
    }

    /**
     * 构架一条记录,更新索引时候用
     * @param id
     * @return
     */
    private BlogTitle getBlogListById(Long id) {
        return new BlogTitle(1000L, "Springboot 零基础入门", "springboot基础(1):IDEA构建Springboot项目的操作步骤如下");
    }

    /**
     * 查询指定id
     * @param id
     * @return
     */
    private BlogTitle selectById(Long id) {
        List<BlogTitle> blogList = getBlogList();
        for (BlogTitle blogTitle : blogList) {
            if (blogTitle.getId().equals(id)) {
                return blogTitle;
            }
        }
        return null;
    }


    /**
     * 查询指定id
     * @param id
     * @return
     */
    private BlogTitle selectById(String id) {
        return selectById(Long.parseLong(id));
    }


    /**
     * 创建索引
     *
     * @return
     */
    @GetMapping("/createIndex")
    public String createIndex() {
        List<BlogTitle> list = getBlogList();//查询数据源,这里直接构造一些数据

        // 创建文档的集合
        Collection<Document> docs = new ArrayList<>();
        for (BlogTitle blogTitle : list) {
            // 创建文档对象
            Document document = new Document();

            // StringField: 这个 Field 用来构建一个字符串Field,不分析,会索引,Field.Store控制存储
            // LongPoint、IntPoint 等类型存储数值类型的数据。会分析,会索引,不存储,如果想存储数据还需要使用 StoredField
            // StoredField: 这个 Field 用来构建不同类型,不分析,不索引,会存储
            // TextField: 如果是一个Reader, 会分析,会索引,,Field.Store控制存储
            document.add(new StringField("id", String.valueOf(blogTitle.getId()), Field.Store.YES));
            // Field.Store.YES, 将原始字段值存储在索引中。这对于短文本很有用,比如文档的标题,它应该与结果一起显示。
            // 值以其原始形式存储,即在存储之前没有使用任何分析器。
            document.add(new TextField("title", blogTitle.getTitle(), Field.Store.YES));
            // Field.Store.NO,可以索引,分词,不将字段值存储在索引中。
            // 个人理解:说白了就是为了省空间,如果回表查询,其实无所谓,如果不回表查询,需要展示就要保存,设为YES,无需展示,设为NO即可。
            document.add(new TextField("description", blogTitle.getDescription(), Field.Store.NO));
            docs.add(document);
        }

        // 引入IK分词器,如果需要解决上面版本冲突报错的问,使用`new MyIKAnalyzer()`即可
        Analyzer analyzer = new IKAnalyzer();
        // 索引写出工具的配置对象
        IndexWriterConfig conf = new IndexWriterConfig(analyzer);
        // 设置打开方式:OpenMode.APPEND 会在索引库的基础上追加新索引。OpenMode.CREATE会先清空原来数据,再提交新的索引
        conf.setOpenMode(IndexWriterConfig.OpenMode.CREATE);

        // 索引目录类,指定索引在硬盘中的位置,我的设置为D盘的indexDir文件夹
        // 创建索引的写出工具类。参数:索引的目录和配置信息
        try (Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("d:\\indexDir"));
             IndexWriter indexWriter = new IndexWriter(directory, conf)) {
            // 把文档集合交给IndexWriter
            indexWriter.addDocuments(docs);
            // 提交
            indexWriter.commit();
        } catch (Exception e) {
            log.error("创建索引失败", e);
            return "创建索引失败";
        }
        return "创建索引成功";
    }

3. 简单搜索

这里示例只搜索了title标题部分的内容,改成description则搜索内容部分

/**
     * 简单搜索
     */
    @RequestMapping("/searchText")
    public List<BlogTitle> searchText(String text) throws IOException, ParseException {
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("d:\\indexDir"));
        // 索引读取工具
        IndexReader reader = DirectoryReader.open(directory);
        // 索引搜索工具
        IndexSearcher searcher = new IndexSearcher(reader);
        // 创建查询解析器,两个参数:默认要查询的字段的名称,分词器
        QueryParser parser = new QueryParser("title", new IKAnalyzer());
        // 创建查询对象
        Query query = parser.parse(text);
        // 获取前十条记录
        TopDocs topDocs = searcher.search(query, 10);
        // 获取总条数
        log.info("本次搜索共找到" + topDocs.totalHits + "条数据");
        // 获取得分文档对象(ScoreDoc)数组.SocreDoc中包含:文档的编号、文档的得分
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;
        List<BlogTitle> list = new ArrayList<>();
        for (ScoreDoc scoreDoc : scoreDocs) {
            // 取出文档编号
            int docId = scoreDoc.doc;
            // 根据编号去找文档
            Document doc = reader.document(docId);
            String id = doc.get("id");
            BlogTitle content = selectById(id);
            list.add(content);
        }
        return list;
    }

4. 更新索引

当有记录发生变化时,需要更新某条记录

 /**
     * 更新索引
     *
     * @return
     */
    @GetMapping("/updateIndex")
    public String update() {
        // 创建配置对象
        IndexWriterConfig conf = new IndexWriterConfig(new IKAnalyzer());
        // 创建目录对象
        // 创建索引写出工具
        try (Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("d:\\indexDir"));
             IndexWriter writer = new IndexWriter(directory, conf)) {
            // 获取更新的数据,这里只是演示
            BlogTitle blogTitle = getBlogListById(1001L);

            // 创建新的文档数据
            Document doc = new Document();
            doc.add(new StringField("id", blogTitle.getId()+"", Field.Store.YES));
            doc.add(new TextField("title", blogTitle.getTitle(), Field.Store.YES));
            doc.add(new TextField("description", blogTitle.getDescription(), Field.Store.YES));
            writer.updateDocument(new Term("id", blogTitle.getId()+""), doc);
            // 提交
            writer.commit();
        } catch (Exception e) {
            log.error("更新索引失败", e);
            return "更新索引失败";
        }

        return "更新索引成功";
    }

5. 删除索引

某条记录不存在了,需要删除

 /**
     * 删除索引
     *
     * @return
     */
    @GetMapping("/deleteIndex")
    public String deleteIndex() {
        // 创建配置对象
        IndexWriterConfig conf = new IndexWriterConfig(new IKAnalyzer());
        // 创建目录对象
        // 创建索引写出工具
        try (Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("d:\\indexDir"));
             IndexWriter writer = new IndexWriter(directory, conf)) {
            // 根据词条进行删除
            writer.deleteDocuments(new Term("id", "1001"));
            // 提交
            writer.commit();
        } catch (Exception e) {
            log.error("删除索引失败", e);
            return "删除索引失败";
        }
        return "删除索引成功";
    }

6. 删除全部索引

想要废弃全部索引,删除全部索引

 /**
     * 删除全部索引
     *
     * @return
     */
    @GetMapping("/deleteAllIndex")
    public String deleteAllIndex() {
        // 创建配置对象
        IndexWriterConfig conf = new IndexWriterConfig(new IKAnalyzer());
        // 创建目录对象
        // 创建索引写出工具
        try (Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("d:\\indexDir"));
             IndexWriter writer = new IndexWriter(directory, conf)) {
            // 删除全部索引
            writer.deleteAll();
            // 提交
            writer.commit();
        } catch (Exception e) {
            log.error("删除索引失败", e);
            return "删除索引失败";
        }
        return "删除索引成功";
    }

Springboot整合Lucene复杂搜索

1. 同时标题和内容中查找关键词

例如 想要搜索关键词,而关键词既可能在标题中,也可能在文本内容中。


    /**
     * 一个关键词,在多个字段里面搜索
     */
    @RequestMapping("/searchTextMore")
    public List<BlogTitle> searchTextMore(String text) throws IOException, ParseException {
        String[] str = {"title", "description"};
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("d:\\indexDir"));
        // 索引读取工具
        IndexReader reader = DirectoryReader.open(directory);
        // 索引搜索工具
        IndexSearcher searcher = new IndexSearcher(reader);
        // 创建查询解析器,两个参数:默认要查询的字段的名称,分词器
        MultiFieldQueryParser parser = new MultiFieldQueryParser(str, new IKAnalyzer());
        // 创建查询对象
        Query query = parser.parse(text);
        // 获取前十条记录
        TopDocs topDocs = searcher.search(query, 100);
        // 获取总条数
        log.info("本次搜索共找到" + topDocs.totalHits + "条数据");
        // 获取得分文档对象(ScoreDoc)数组.SocreDoc中包含:文档的编号、文档的得分
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;
        List<BlogTitle> list = new ArrayList<>();
        for (ScoreDoc scoreDoc : scoreDocs) {
            // 取出文档编号
            int docId = scoreDoc.doc;
            // 根据编号去找文档
            Document doc = reader.document(docId);
            String id = doc.get("id");
            BlogTitle content = selectById(id);
            list.add(content);
        }
        return list;
    }

2. 搜索结果高亮显示关键词

/**
     * 搜索结果高亮显示
     */
    @RequestMapping("/searchTextHighlighter")
    public List<BlogTitle> searchTextHighlighter(String text) throws IOException, ParseException, InvalidTokenOffsetsException {
        String[] str = {"title", "description"};
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("d:\\indexDir"));
        // 索引读取工具
        IndexReader reader = DirectoryReader.open(directory);
        // 索引搜索工具
        IndexSearcher searcher = new IndexSearcher(reader);
        // 创建查询解析器,两个参数:默认要查询的字段的名称,分词器
        MultiFieldQueryParser parser = new MultiFieldQueryParser(str, new IKAnalyzer());
        // 创建查询对象
        Query query = parser.parse(text);
        // 获取前100条记录
        TopDocs topDocs = searcher.search(query, 100);
        // 获取总条数
        log.info("本次搜索共找到" + topDocs.totalHits + "条数据");

        //高亮显示
        SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter("<span style='color:red'>", "</span>");
        Highlighter highlighter = new Highlighter(simpleHTMLFormatter, new QueryScorer(query));
        //高亮后的段落范围在100字内
        Fragmenter fragmenter = new SimpleFragmenter(100);
        highlighter.setTextFragmenter(fragmenter);

        // 获取得分文档对象(ScoreDoc)数组.SocreDoc中包含:文档的编号、文档的得分
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;
        List<BlogTitle> list = new ArrayList<>();
        for (ScoreDoc scoreDoc : scoreDocs) {
            // 取出文档编号
            int docId = scoreDoc.doc;
            // 根据编号去找文档
            Document doc = reader.document(docId);
            String id = doc.get("id");
            BlogTitle content = selectById(id);
            //处理高亮字段显示
            String title = highlighter.getBestFragment(new IKAnalyzer(), "title", doc.get("title"));
            if (title == null) {
                title = content.getTitle();
            }
            // 因为创建索引的时候description设置的Field.Store.NO,所以这里doc没有description数据,取不出来值,设为YES则可以,可以断点看一下,直接设置content.getDescription()也可以高亮显示
//            String description = highlighter.getBestFragment(new IKAnalyzer(), "description", doc.get("description"));
//            if (description == null) {
//                description = content.getDescription();
//            }
//            content.setDescription(description);
            content.setDescription(content.getDescription());
            content.setTitle(title);
            list.add(content);
        }
        return list;
    }

3. 分页搜索

/**
     * 分页搜索
     */
    @RequestMapping("/searchTextPage")
    public List<BlogTitle> searchTextPage(String text) throws IOException, ParseException, InvalidTokenOffsetsException {
        String[] str = {"title", "description"};
        int page = 1;
        int pageSize = 5;
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("d:\\indexDir"));
        // 索引读取工具
        IndexReader reader = DirectoryReader.open(directory);
        // 索引搜索工具
        IndexSearcher searcher = new IndexSearcher(reader);
        // 创建查询解析器,两个参数:默认要查询的字段的名称,分词器
        MultiFieldQueryParser parser = new MultiFieldQueryParser(str, new IKAnalyzer());
        // 创建查询对象
        Query query = parser.parse(text);
        // 分页获取数据
        TopDocs topDocs = searchByPage(page, pageSize, searcher, query);
        // 获取总条数
        log.info("本次搜索共找到" + topDocs.totalHits + "条数据");

        //高亮显示
        SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter("<span style='color:red'>", "</span>");
        Highlighter highlighter = new Highlighter(simpleHTMLFormatter, new QueryScorer(query));
        //高亮后的段落范围在100字内
        Fragmenter fragmenter = new SimpleFragmenter(100);
        highlighter.setTextFragmenter(fragmenter);

        // 获取得分文档对象(ScoreDoc)数组.SocreDoc中包含:文档的编号、文档的得分
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;
        List<BlogTitle> list = new ArrayList<>();
        for (ScoreDoc scoreDoc : scoreDocs) {
            // 取出文档编号
            int docId = scoreDoc.doc;
            // 根据编号去找文档
            Document doc = reader.document(docId);
            BlogTitle content = selectById(doc.get("id"));
            //处理高亮字段显示
            String title = highlighter.getBestFragment(new IKAnalyzer(), "title", doc.get("title"));
            if (title == null) {
                title = content.getTitle();
            }
            String description = highlighter.getBestFragment(new IKAnalyzer(), "description", content.getDescription());
            content.setDescription(description);
            content.setTitle(title);
            list.add(content);
        }
        return list;
    }

    private TopDocs searchByPage(int page, int perPage, IndexSearcher searcher, Query query) throws IOException {
        TopDocs result;
        if (query == null) {
            log.info(" Query is null return null ");
            return null;
        }
        ScoreDoc before = null;
        if (page != 1) {
            TopDocs docsBefore = searcher.search(query, (page - 1) * perPage);
            ScoreDoc[] scoreDocs = docsBefore.scoreDocs;
            if (scoreDocs.length > 0) {
                before = scoreDocs[scoreDocs.length - 1];
            }
        }
        result = searcher.searchAfter(before, query, perPage);
        return result;
    }

4. 多关键词联合搜索

/**
     * 多关键词搜索
     */
    @GetMapping("/searchTextMoreParam")
    public List<BlogTitle> searchTextMoreParam(String text) throws IOException, ParseException, InvalidTokenOffsetsException {
        String[] str = {"title", "description"};
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("d:\\indexDir"));
        // 索引读取工具
        IndexReader reader = DirectoryReader.open(directory);
        // 索引搜索工具
        IndexSearcher searcher = new IndexSearcher(reader);

        //多条件查询构造
        BooleanQuery.Builder builder = new BooleanQuery.Builder();

        // 条件一
        MultiFieldQueryParser parser = new MultiFieldQueryParser(str, new IKAnalyzer());
        // 创建查询对象
        Query query = parser.parse(text);
        builder.add(query, BooleanClause.Occur.MUST);
        // 条件二
        // TermQuery不使用分析器所以建议匹配不分词的Field域(StringField, )查询,比如价格、分类ID号等。这里只能演示个ID了。。。
        Query termQuery = new TermQuery(new Term("id", "1001"));
        builder.add(termQuery, BooleanClause.Occur.MUST);
        // 获取前十条记录
        TopDocs topDocs = searcher.search(builder.build(), 100);
        // 获取总条数
        log.info("本次搜索共找到" + topDocs.totalHits + "条数据");
        //高亮显示
        SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter("<span style='color:red'>", "</span>");
        Highlighter highlighter = new Highlighter(simpleHTMLFormatter, new QueryScorer(query));
        //高亮后的段落范围在100字内
        Fragmenter fragmenter = new SimpleFragmenter(100);
        highlighter.setTextFragmenter(fragmenter);

        // 获取得分文档对象(ScoreDoc)数组.SocreDoc中包含:文档的编号、文档的得分
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;
        List<BlogTitle> list = new ArrayList<>();
        for (ScoreDoc scoreDoc : scoreDocs) {
            // 取出文档编号
            int docId = scoreDoc.doc;
            // 根据编号去找文档
            Document doc = reader.document(docId);
            BlogTitle content = selectById(doc.get("id"));
            //处理高亮字段显示
            String title = highlighter.getBestFragment(new IKAnalyzer(), "title", doc.get("title"));
            if (title == null) {
                title = content.getTitle();
            }
            String description = highlighter.getBestFragment(new IKAnalyzer(), "description", content.getDescription());
            content.setDescription(description);
            content.setTitle(title);
            list.add(content);
        }
        return list;
    }

关于IK扩展分词

什么分词?什么是扩展分词?

什么是分词?
例如“书上的苹果掉下来砸中了牛顿”。句子中的苹果就构成一个分词,苹和前面的文字的不能组词构成“的苹”,也不能组词叫“果掉”,但是苹和果构成一个组词,叫做苹果。
什么是扩展分词?
就是希望增加分词,而分词器本身不带有的,例如我市的著名武术巨星马保国同志在巴黎奥运会取得了马拉松金牌的好成绩。分词器并不会提前内置马保国的名字,但是马保国是一个人名,构成一个分词,在搜索的时候单独搜索其中任何一个字都没有意义,需要搜索整个关键词,才构成实际价值。因此分词中添加马保国,搜索时IK分词会将其解释为一个分词。

自定义分词和停止分词

  1. 需要在resources目录下新建文件IKAnalyzer.cfg.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
    <comment>IKAnalyzer扩展配置</comment>
    <!--用户的扩展字典 -->
    <entry key="ext_dict">extend.dic</entry>
    <!--用户扩展停止词字典 -->
    <entry key="ext_stopwords">stop.dic</entry>
</properties>
  1. 添加分词文件
    extend.dic
马保国
王小羽
  1. 添加停止分词

与分词的意思相反,就是破坏构成的分词,不希望既有分词构建分词
stop.dic

的
地
吗
  • 18
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值