POI Excel导入导出(上)

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

在日常开发中,常常会遇到Excel导入导出的需求。记得刚入行不久,我接到一个需求,客户希望把数据库的数据导出到Excel表,而且末尾要把对应的衣服图片塞进表格中。当时自学入行才一个月,根本没遇到过处理Excel的情况,慌得不行。心想,Java和Excel八竿子打不着,我怎么用Java代码去操作一个个表格呢?

后来经过百度,才发现Apache早就写好了工具类,调用API就完事了。这个工具类就是POI,也就是今天的主角。

这个系列会分为好多篇,目的不是为了提供一个完美的Excel工具类,而是在这个过程中复习并应用反射、泛型和注解处理实际问题。老实说,无论效率还是稳定性,我们自己封装的Utils都不及阿里开源的EasyExcel,但我希望通过PoiExcelUtils的迭代演示,让大家掌握Excel导入导出的大致流程以及封装工具类的思路,以后遇到其他类似的需求也能驾轻就熟。

POI简介

常见的Excel操作工具有JXL、POI等。JXL只能对Excel进行操作,属于比较老的框架,而且只支持到Excel95-2000版本,现在已经停止更新和维护,这里不做过多介绍。POI是Apache的项目,可以对微软/WPS的Word、Excel、PPT甚至OutLook进行操作。说这句话目的只有一个,就是告诉大家POI不仅仅只能处理Excel,Word、PPT这些办公组件也同样适用。

但实际开发中处理Excel的场景几乎占了95%以上,所以我们只介绍POI处理Excel的部分。

如果大家稍微回忆一下,会发现好像生活中只听过Excel2003和Excel2007,没有什么Excel2004这么一说。正如Java1.5版本是重要的分水岭一样,Excel2007也是一个分水岭。

Excel2003是一个特有的二进制格式,其核心结构是复合文档类型的结构,存储数据量较小,文件后缀是xls。

Excel2007的核心结构则是XML,采用的是基于XML的压缩格式,占用空间更小,操作效率更高,文件后缀是xlsx。

如何验证呢?

首先在桌面任意新建一个student_info.xlsx文件,然后把后缀改为.zip,随即解压得到:

看,遍地的xml文件。

Java是一门面向对象的语言,可以说万物皆对象,POI也是如此。在后面使用POI的过程中,我们将会遇到下面三个的实现类:

  • Workbook
  • Sheet
  • CellStyle

刚好是对上面几个XML文件的抽象,操作它们等同于操作Excel文件本身。

POI提供了HSSF、XSSF以及SXSSF三种方式操作Excel:

  • HSSF:Excel97-2003版本,扩展名为.xls。一个sheet最大行数65536,最大列数256
  • XSSF:Excel2007版本开始,扩展名为.xlsx。一个sheet最大行数1048576,最大列数16384(百万数据)
  • SXSSF:在XSSF基础上,POI3.8版本开始提供的支持低内存占用的操作方式,扩展名为.xlsx

我们会基于XSSF做介绍,反正实际开发推荐使用EasyExcel,我们没必要对POI了解太深。

<dependencies>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<optional>true</optional>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-test</artifactId>
		<scope>test</scope>
	</dependency>
	<dependency>
		<groupId>org.apache.poi</groupId>
		<artifactId>poi</artifactId>
		<version>4.1.1</version>
	</dependency>
	<dependency>
		<groupId>org.apache.poi</groupId>
		<artifactId>poi-ooxml</artifactId>
		<version>4.1.1</version>
	</dependency>
</dependencies>

下载Excel文件,后面要用: 百度网盘 请输入提取码  密码: sav4

简单的导入

/**
 * 最简单的Excel测试
 * 目前为止,我们只是引入了POI依赖,然后准备了一个Excel文件
 * 下面写了两段代码,大家花时间阅读一下
 */
public class SimpleTest {
    @Test
    public void testSimpleRead() throws IOException {
        // 获取工作薄(把路径换成你本地的)
        XSSFWorkbook workbook = new XSSFWorkbook("/Users/bravo1988/Desktop/student_info.xlsx");
        // 获取工作表。一个工作薄中可能有多个工作表,比如sheet1 sheet2,可以根据下标,也可以根据sheet名称
        XSSFSheet sheet = workbook.getSheetAt(0);

        /**
         * 方式1 增强for,Row代表行,Cell代表单元格
         */
        // 遍历每一行
        for (Row row : sheet) {
            // 遍历当前行的每个单元格
            for (Cell cell : row) {
                // 获取单元格类型
                CellType cellType = cell.getCellType();
                // 根据类型选择匹配的getXxxValue()方法,比如你判断当前单元格的值是BOOLEAN类型的,你就要用getBooleanCellValue()
                if (cellType == CellType.NUMERIC) {
                    // 你可以试着把这里的getNumericCellValue()改成getStringCellValue(),观察会发生什么
                    System.out.print(cell.getNumericCellValue() + "\t");
                } else if (cellType == CellType.BOOLEAN) {
                    System.out.print(cell.getBooleanCellValue() + "\t");
                } else if (cellType == CellType.STRING) {
                    System.out.print(cell.getStringCellValue() + "\t");
                }
            }
            System.out.println("\n------------------------------------------");
        }

        System.out.println("\n==========================================\n");

        /**
         * 方式2 普通for
         */
        // 获取最后一行
        int lastRowNum = sheet.getLastRowNum();
        // 从第一行开始遍历,直到最后一行
        for (int j = 0; j <= lastRowNum; j++) {
            // 获取当前行
            XSSFRow row = sheet.getRow(j);
            if (row != null) {
                // 获取当前行最后一个单元格
                short cellNum = row.getLastCellNum();
                // 从第一个单元格开始遍历,直到最后一个单元格
                for (int k = 0; k < cellNum; k++) {
                    XSSFCell cell = row.getCell(k);
                    CellType cellType = cell.getCellType();
                    // 注意,我们的EXCEL有NUMERIC、STRING和BOOLEAN三种类型,但这里省略了BOOLEAN,会发生什么呢?
                    if (cellType == CellType.NUMERIC) {
                        System.out.print(cell.getNumericCellValue() + "\t");
                    } else if (cellType == CellType.STRING) {
                        System.out.print(cell.getStringCellValue() + "\t");
                    }
                }
            }
            System.out.println("\n------------------------------------------");
        }
    }
}

输出结果

学生表1

------------------------------------------

姓名      年龄      籍贯      生日      身高      是否内地

------------------------------------------

周星驰    58.0     香港     22819.0  174.0      false

------------------------------------------

李健       46.0    哈尔滨   27295.0  174.5       1.0

------------------------------------------

周深       28.0     贵州     33876.0  161.0       true

------------------------------------------

==========================================

学生表1

------------------------------------------

姓名      年龄      籍贯      生日      身高      是否内地

------------------------------------------

周星驰    58.0     香港     22819.0  174.0      

------------------------------------------

李健       46.0    哈尔滨   27295.0  174.5       1.0

------------------------------------------

周深       28.0     贵州     33876.0  161.0      

------------------------------------------

关于POI的数值类型

观察上面的代码和输出结果,作出以下总结:

  • POI抽象出了CellType的概念,必须判断单元格数据类型后再用对应的方法取值。如果类型枚举不完整,就无法读取到对应的数值(比如第二个for,没有判断Boolean),或者明明是A类型,但是用了getBTypeValue()也会报错
  • POI对数值类型的描述比较笼统,NUMERIC对应了传统意义上的整数、小数、日期等一切和数字有关的类型(比如生日,变成了22819.0),在某些场景下会造成困扰

在POI的世界里,它把Excel单元格的数据类型抽象为CellType枚举,里面定义了7种类型:

/**
 * @since POI 3.15 beta 3
 */
public enum CellType {
    /**
     * 未知类型,用于表示初始化之前或缺少具体类型的状态。仅供内部使用
     */
    @Internal(since="POI 3.15 beta 3")
    _NONE(-1),

    /**
     * 数字类型(整数、小数/分数、日期)
     */
    NUMERIC(0),
    
    /** 文本类型 */
    STRING(1),
    
    /**
     * 计算公式类型(Excel经常会有sum/average等计算公式)
     * @see FormulaType
     */
    FORMULA(2),
    
    /**
     * 空类型
     */
    BLANK(3),
    
    /**
     * Boolean类型
     */
    BOOLEAN(4),
    
    /**
     * 错误类型
     * @see FormulaError
     */
    ERROR(5);

    /**
     * @since POI 3.15 beta 3
     * @deprecated POI 3.15 beta 3
     */
    private final int code;
    
    /**
     * @since POI 3.15 beta 3
     * @deprecated POI 3.15 beta 3
     */
    private CellType(int code) {
        this.code = code;
    }
    
    /**
     * @since POI 3.15 beta 3.
     * @deprecated POI 3.15 beta 3. Used to transition code from <code>int</code>s to <code>CellType</code>s.
     */
    public static CellType forInt(int code) {
        for (CellType type : values()) {
            if (type.code == code) {
                return type;
            }
        }
        throw new IllegalArgumentException("Invalid CellType code: " + code);
    }
    
    /**
     * @since POI 3.15 beta 3
     * @deprecated POI 3.15 beta 3
     */
    public int getCode() {
        return code;
    }
    
}
  • NUMERIC:对应Excel的所有数字类型(整数、小数/分数、日期),底层用double保存数据
  • STRING:对应Excel的所有文本类型
  • BOOLEAN:Excel中只有true/false是Boolean类型,1/0在POI中对应NUMERIC,是普通的1/0
  • _NONE:POI内部使用,不用管
  • FORMULA/ERROR:这两个是一伙的,和计算公式有关,我们很少接触
  • BLANK:空

POI实在太懒了,竟然期望用NUMERIC表示整数、小数、分数、日期等所有和数字有关的类型。我们通过上面的案例可以观察到,NUMERIC底层其实使用的是double,统一保存整数、小数、分数及日期,所以上面案例中的年龄、身高、生日导出到Excel后都变成了小数。

优化导入

上面我们说过,POI中只有NUMERIC,所以Excel中的整数、小数、分数及日期被读取到后都用double存储:

如果我们希望内存中解析得到的类型是数据原本的类型,就必须根据CellType判断并进行类型转换。

好在POI有点良心,提供了DateUtil,可以从NUMERIC中拆出Date类型。

public class SimpleTestTwo {
    @Test
    public void testSimpleRead() throws IOException {
        // 获取工作薄(把路径换成你本地的)
        XSSFWorkbook workbook = new XSSFWorkbook("/Users/bravo1988/Desktop/student_info.xlsx");
        // 获取工作表。一个工作薄中可能有多个工作表,比如sheet1 sheet2,可以根据下标,也可以根据sheet名称
        XSSFSheet sheet = workbook.getSheetAt(0);

        // 这里采用普通for
        int lastRowNum = sheet.getLastRowNum();
        for (int i = 0; i <= lastRowNum; i++) {
            XSSFRow row = sheet.getRow(i);
            if (row != null) {
                short cellNum = row.getLastCellNum();
                for (int k = 0; k < cellNum; k++) {
                    XSSFCell cell = row.getCell(k);
                    // 要判断类型并采用对应的get方法,由于比较繁琐,我们抽取成方法
                    System.out.print(getValue(cell) + "\t");
                }
            }
            System.out.println("\n------------------------------------------");
        }

    }

    /**
     * 提供POI数据类型 到 Java数据类型的转换,最终都返回String
     *
     * @param cell
     * @return
     */
    private String getValue(Cell cell) {
        if (cell == null) {
            return "";
        }

        // 常用的一般就这三大类:STRING、NUMERIC、BOOLEAN,几乎没有别的类型了。但NUMERIC要细分,特别注意
        switch (cell.getCellType()) {
            case STRING:
                return cell.getRichStringCellValue().getString().trim();

            // EXCEL的日期和数字都被POI整合为NUMERIC,这里把它们重新拆开
            case NUMERIC:
                // DateUtil是POI内部提供的日期工具类,可以把原本是日期类型的NUMERIC转为Java的Data类型
                if (DateUtil.isCellDateFormatted(cell)) {
                    Date javaDate = DateUtil.getJavaDate(cell.getNumericCellValue());
                    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(javaDate);
                } else {
                    // 无论EXCEL中是58还是58.0,数值类型在POI中最终都被解读为double。这里的解决办法是通过BigDecimal先把double先转成字符串,如果是.0结尾,把.0去掉
                    String strCell = "";
                    double num = cell.getNumericCellValue();
                    BigDecimal bd = new BigDecimal(Double.toString(num));
                    strCell = bd.toPlainString();
                    // 去除 浮点型 自动加的 .0
                    if (strCell.endsWith(".0")) {
                        strCell = strCell.substring(0, strCell.indexOf("."));
                    }
                    return strCell;
                }

            case BOOLEAN:
                boolean booleanCellValue = cell.getBooleanCellValue();
                return String.valueOf(booleanCellValue);
            default:
                return "";
        }
    }
}

输出结果

学生表1    

------------------------------------------------------

姓名    年龄 籍贯            生日       身高 是否内地

------------------------------------------------------

周星驰   58      香港     1962-06-22 00:00:00 174     false

------------------------------------------------------

李健    46      哈尔滨   1974-09-23 00:00:00      174.5       true

------------------------------------------------------

周深     28       贵州     1992-09-29 00:00:00       161          true

------------------------------------------------------

简单的导出

public class SimpleTestThree {

    @Test
    public void testSimpleWrite() throws IOException, ParseException {
        // 创建工作薄
        XSSFWorkbook workbook = new XSSFWorkbook();
        // 创建工作表
        XSSFSheet sheet = workbook.createSheet("student");
        // 构造假数据
        List<Student> studentList = new ArrayList<>();
        studentList.add(new Student(1L, "周星驰", 58, "香港", new SimpleDateFormat("yyyy-MM-dd").parse("1962-6-22"), 174.0, false));
        studentList.add(new Student(2L, "李健", 46, "哈尔滨", new SimpleDateFormat("yyyy-MM-dd").parse("1974-9-23"), 174.5, true));
        studentList.add(new Student(3L, "周深", 28, "贵州", new SimpleDateFormat("yyyy-MM-dd").parse("1992-9-29"), 161.0, true));

        for (int i = 0; i < studentList.size(); i++) {
            Student student = studentList.get(i);
            // 创建行
            XSSFRow row = sheet.createRow(i);
            // 在当前行创建6个单元格,并设置数据(id不导出)
            row.createCell(0).setCellValue(student.getName());
            row.createCell(1).setCellValue(student.getAge());
            row.createCell(2).setCellValue(student.getAddress());
            row.createCell(3).setCellValue(student.getBirthday());
            row.createCell(4).setCellValue(student.getHeight());
            row.createCell(5).setCellValue(student.getIsMainlandChina());
        }

        FileOutputStream out = new FileOutputStream("/Users/bravo1988/Desktop/student_info_export.xlsx");
        workbook.write(out);
        out.flush();
        out.close();
        workbook.close();
        System.out.println("导出成功!");
    }

    @Data
    @AllArgsConstructor
    static class Student {
        private Long id;
        private String name;
        private Integer age;
        private String address;
        private Date birthday;
        private Double height;
        private Boolean isMainlandChina;
    }

}

观察导出结果,其实还是NUMERIC的问题。Java的Date、Integer、Double经过POI转换后都变成了double,所以最终在Excel中呈现出来的就是一个数值,而日期也就成了现在看到的22819。

来看看Java中的类型是怎么变成POI的NUMERIC的吧:

setCellValue()有多个重载方法,对于Integer,其实都可以用setCellValue(double)接收:

public static void main(String[] args) {
    // age会自动类型提升为double,输出58.0
    Integer age = 58;
    System.out.println((double)age);
}

Double就更不用说了,直接拆箱成double即可。而Date其实最终也会调用setCellValue(double):

DateUtil.getExcelDate()会把Date变成double,然后调用setCellValue(double)。

总之,NUMERIC类型底层就是double存储,Java的Date/Integer/Double等数值在转换到POI的NUMERIC后都是double,而Excel只不过是原样展示罢了。

如果我们希望导出时Excel按我们期望的格式展示,就要在代码中明确:我希望你按xx格式展示这个double。

那么,如何让Excel把22819.0解读为一个日期格式呢?

告诉Excel如何解读这个double类型的数字即可!比如20200329,无论解读为“两千零二十万叁佰二十九”或者“2020年3月29日”都是对的,当Excel认为这是一个日期时,就会展示成日期。

public class SimpleTestFour {

    @Test
    public void testSimpleWrite() throws IOException, ParseException {
        // 创建工作薄
        XSSFWorkbook workbook = new XSSFWorkbook();
        // 创建工作表
        XSSFSheet sheet = workbook.createSheet("student");
        // 构造假数据
        List<Student> studentList = new ArrayList<>();
        studentList.add(new Student(1L, "周星驰", 58, "香港", new SimpleDateFormat("yyyy-MM-dd").parse("1962-6-22"), 174.0, false));
        studentList.add(new Student(2L, "李健", 46, "哈尔滨", new SimpleDateFormat("yyyy-MM-dd").parse("1974-9-23"), 174.5, true));
        studentList.add(new Student(3L, "周深", 28, "贵州", new SimpleDateFormat("yyyy-MM-dd").parse("1992-9-29"), 161.0, true));

        for (int i = 0; i < studentList.size(); i++) {
            Student student = studentList.get(i);
            // 创建行
            XSSFRow row = sheet.createRow(i);
            // 在当前行创建6个单元格,并设置数据(id不导出)
            row.createCell(0).setCellValue(student.getName());
            row.createCell(1).setCellValue(student.getAge());
            row.createCell(2).setCellValue(student.getAddress());

            // 创建cell
            XSSFCell birthdayCell = row.createCell(3);
            // 设置样式!!!指定Excel对数值的解读方式
            XSSFCellStyle cellStyle = workbook.createCellStyle();
            XSSFDataFormat dataFormat = workbook.createDataFormat();
            // 样式随意,可以是yyyy-MM-dd或yyyy/MM-dd都行
            cellStyle.setDataFormat(dataFormat.getFormat("yyyy/MM/dd"));
            birthdayCell.setCellStyle(cellStyle);
            // 上面设置完样式后,才最终设置值
            birthdayCell.setCellValue(student.getBirthday());

            row.createCell(4).setCellValue(student.getHeight());
            row.createCell(5).setCellValue(student.getIsMainlandChina());
        }

        FileOutputStream out = new FileOutputStream("/Users/bravo1988/Desktop/student_info_export_2.xlsx");
        workbook.write(out);
        out.flush();
        out.close();
        workbook.close();
        System.out.println("导出成功!");
    }

    @Data
    @AllArgsConstructor
    static class Student {
        private Long id;
        private String name;
        private Integer age;
        private String address;
        private Date birthday;
        private Double height;
        private Boolean isMainlandChina;
    }
}

有同学可能发现了:

row.createCell(3).setCellValue(new SimpleDateFormat("yyyy-MM-dd").format(student.getBirthday()));

这样也能输出1962-6-22。

没错,但是本质上来说,这是把Date格式化为字符串,也就是POI的STRING,然后按照文本格式输出到Excel。下次再导入时,其实对应POI的STRING,而不再是NUMERIC了,会造成导入导出数据类型产生变化。

至于为什么Integer的age转为NUMERIC时变成了58.0,输出到Excel却变成了58,我认为可能纯粹是因为Excel默认的数据类型是常规,即使你输入58.0,也只会显示58。

这里就不去纠结了,反正我们到时用的是EasyExcel,而不是自己封装POI。

模板导出

把student_info.xlsx改成下面的样子,做成一个模板:

我们希望数据库查询出数据后,读取模板并按照模板第一行的样式填充:

public class SimpleTestFive {

    /**
     * 按模板样式导出Excel
     *
     * @throws ParseException
     * @throws IOException
     */
    @Test
    public void testWriteWithStyle() throws IOException, ParseException {
        // 查询数据
        List<Student> studentList = new ArrayList<>();
        studentList.add(new Student(1L, "周深", 28, "贵州", new SimpleDateFormat("yyyy-MM-dd").parse("1992-9-29"), 161.0, true));
        studentList.add(new Student(2L, "李健", 46, "哈尔滨", new SimpleDateFormat("yyyy-MM-dd").parse("1974-9-23"), 174.5, true));
        studentList.add(new Student(3L, "周星驰", 58, "香港", new SimpleDateFormat("yyyy-MM-dd").parse("1962-6-22"), 174.0, false));

        // 读取模板
        XSSFWorkbook workbook = new XSSFWorkbook("/Users/bravo1988/Desktop/student_template.xlsx");
        // 获取模板sheet
        XSSFSheet sheet = workbook.getSheetAt(0);
        // 找到数据起始行(前两行是标题和表头,要跳过,所以是getRow(2))
        XSSFRow dataTemplateRow = sheet.getRow(2);
        // 构造一个CellStyle数组,用来存放单元格样式。一行有N个单元格,长度就设置为N
        CellStyle[] cellStyles = new CellStyle[dataTemplateRow.getLastCellNum()];
        for (int i = 0; i < cellStyles.length; i++) {
            // 收集每一个格子对应的格式,你可以理解为准备了一把“格式刷”
            cellStyles[i] = dataTemplateRow.getCell(i).getCellStyle();
        }

        // 创建单元格,并设置样式和数据
        for (int i = 0; i < studentList.size(); i++) {
            // 注意是i+2,模板前两行是大标题和表头。你可能看着难受,想把上面for的i改为i+2,千万别。因为studentList必须从0开始取值
            XSSFRow row = sheet.createRow(i + 2);
            // 为每一行创建单元格并设置数据
            Student student = studentList.get(i);


            XSSFCell nameCell = row.createCell(0);// 创建单元格
            nameCell.setCellValue(student.getName());         // 设置值
            nameCell.setCellStyle(cellStyles[0]);             // 设置单元格样式

            XSSFCell ageCell = row.createCell(1);
            ageCell.setCellValue(student.getAge());
            ageCell.setCellStyle(cellStyles[1]);

            XSSFCell addressCell = row.createCell(2);
            addressCell.setCellValue(student.getAddress());
            addressCell.setCellStyle(cellStyles[2]);

            /**
             * 你可能有疑问,这里是日期类型,是不是要和上一次一样,设置单元格样式为日期类型?
             * 这回不用了,因为上面已经拷贝了模板的样式,生日一栏就是按日期类型展示的
             */
            XSSFCell birthdayCell = row.createCell(3);
            birthdayCell.setCellValue(student.getBirthday());
            birthdayCell.setCellStyle(cellStyles[3]);

            XSSFCell heightCell = row.createCell(4);
            heightCell.setCellValue(student.getHeight());
            heightCell.setCellStyle(cellStyles[4]);

            XSSFCell mainLandChinaCell = row.createCell(5);
            mainLandChinaCell.setCellValue(student.getIsMainlandChina());
            mainLandChinaCell.setCellStyle(cellStyles[5]);
        }

        // 输出
        FileOutputStream out = new FileOutputStream("/Users/bravo1988/Desktop/student_info_export_3.xlsx");
        workbook.write(out);
        out.flush();
        out.close();
        workbook.close();
        System.out.println("导出成功!");
    }

    @Data
    @AllArgsConstructor
    static class Student {
        private Long id;
        private String name;
        private Integer age;
        private String address;
        private Date birthday;
        private Double height;
        private Boolean isMainlandChina;
    }
}

你看,姓名一栏是不是所有人的样式都是 加粗+红色,和模板的姓名样式一样呢?

希望通过这篇文章,大家不再惧怕POI,往后能自信地使用各种第三方Excel工具类。

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

进群,大家一起学习,一起进步,一起对抗互联网寒冬

  • 28
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值