使用自定义注解和SpringAOP实现通用日志记录

在软件开发中,针对用户操作日志的记录是一个普遍且重要的需求,旨在跟踪用户行为、保障系统安全及进行后续的问题追踪与性能分析。

传统上,若在每次方法调用时都手动构造日志实体并同步插入数据库,不仅会导致代码冗余且难以维护,还会因同步操作而拖慢系统性能。

为了优化这一流程, 可以使用自定义注解和aop的方式进行对日志的记录, 能动态记录指定记录的参数, 如请求参数, 响应参数, 操作时间, 操作用户的用户名, 方法耗时等等, 可以灵活拓展,而无需在每个业务逻辑点中显式编写日志代码。

controller上的方法上添加注解即可实现日志记录

在这里插入图片描述

改AOP执行流程图如下, 具体实现如下5步骤
在这里插入图片描述

1. 定义注解和业务类操作类型

  • BusinessType 业务操作类型(可指定具体模块下具体操作)
  • OperatorType 操作人类别(user/vip/admin)

Log注解, 存放路径: com.tiantian.common.log.annotation

import java.lang.annotation.*;

/**
 * 自定义操作日志记录注解
 *
 * @author ruoyi
 */
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
    /**
     * 业务操作类型
     */
    BusinessType businessType() default BusinessType.OTHER;

    /**
     * 操作人类别
     */
    OperatorType operatorType() default OperatorType.NEWCOMER;

    /**
     * 是否保存请求的参数
     */
    boolean isSaveRequestData() default true;

    /**
     * 是否保存响应的参数
     */
    boolean isSaveResponseData() default true;


    /**
     * 排除指定的请求参数
     */
    String[] excludeParamNames() default {};

}

BusinessType业务操作类型(可自行拓展), 其中number便于条件查询, description用于前端下拉框选项展示, 存放路径: com.tiantian.common.log.enums

/**
 * 业务操作类型
 *
 * @author ruoyi
 */
public enum BusinessType {

    CAPTCHA(0,"获取验证码"),
    REGISTER_BATCH(1,"批量注册"),
    REGISTER(2,"用户注册"),
    SET_TOPIC(3,"设置题目"),
    UPDATE_TOPIC(4,"更新题目信息"),
    DEL_TOPIC_LOGIC(5,"逻辑删除题目"),
    DEL_TOPIC_permanent(6,"永久删除题目"),
    RECOVER_TOPIC(7,"恢复所删题目"),
    NEWCOMER_SUBMIT_TOPIC(8,"候选人提交解题"),
    NEWCOMER_UPDATE_ANS(9,"候选人修改答案"),
    UPDATE_NEWCOMER(10,"管理员更新候选人信息"),
    JUDGE_SETTING(11,"判题人设置"),
    JUDGER_DELETE(12,"删除判题人"),
    JUDGE_TOPIC(13,"题目评分"),
    ENROLL(14,"一键录取候选人"),
    NOT_ENROLL(15,"取消录取资格"),
    SCORE_LINE(16,"分数线设置"),
    ANS_TIME(17,"答题时间设置"),
    REGISTER_SETTING(18,"修改注册开关"),
    EMAIL(19,"系统邮件发送"),
    FILE_EXPORT(20,"文件导出"),
    UPDATE_PROFILE(21,"更新个人信息"),
    UPDATE_PWD(22,"更新密码"),
    ALLOT_NEWCOMER(23,"分配新人"),
    CALC_SCORE(24,"计算零分用户"),
    DEL_ADMIN_REPLYS(25,"删除工作人员作答记录"),
    SET_ENR_STATUS(26,"设置录取状态"),
    DEL_LOG(27,"删除操作日志"),
    CLEAR_LOG(28,"清空操作日志"),
    FILE_UPLOAD(29,"文件上传"),
    DEL_FILE(30,"删除文件"),
    EXP_LOG(31, "导出日志文件"),
    AI_JUDGE(32, "AI判题"),
    OTHER(33,"其它");

    final int number;
    final String description;

    BusinessType(int number, String description) {
        this.number = number;
        this.description = description;
    }

    public int getNumber() {
        return number;
    }

    public String getDescription() {
        return description;
    }
}

OperatorType操作人类别, 便于快速区别操作角色, 存放路径: com.tiantian.common.log.enums

/**
 * 操作人类别
 *
 * @author ruoyi
 */
public enum OperatorType {
    /**
     * 其它
     */
    OTHER,

    /**
     * 管理员
     */
    ADMIN,

    /**
     * 新人
     */
    NEWCOMER
}

2. 定义日志实体

SysOperLog 存放目录 com.tiantian.common.log.domain

import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

/**
 * 操作日志记录表 oper_log
 */
@Data
public class SysOperLog implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 日志主键
     */
    private Long operId;

    /**
     * 操作模块
     */
    private String title;

    /**
     * 业务类型
     */
    private Integer businessType;

    /**
     * 业务类型数组
     */
    private Integer[] businessTypes;

    /**
     * 请求方法
     */
    private String method;

    /**
     * 请求方式
     */
    private String requestMethod;

    /**
     * 操作类别
     */
    private Integer operatorType;

    /**
     * 操作人员
     */
    private String operName;

    /**
     * 请求url
     */
    private String operUrl;

    /**
     * 操作地址
     */
    private String operIp;

    /**
     * 请求参数
     */
    private String operParam;

    /**
     * 返回参数
     */
    private String jsonResult;

    /**
     * 操作状态(0正常 1异常)
     */
    private Integer status;

    /**
     * 错误消息
     */
    private String errorMsg;

    /**
     * 操作时间
     */
    private LocalDateTime operTime;

    /**
     * 消耗的时间
     */
    private Long costTime;
}

3. 创建数据库表并定义Mapper和Xml

3.1 创建日志表

DROP TABLE IF EXISTS `sys_oper_log`;
CREATE TABLE `sys_oper_log`  (
  `oper_id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志主键',
  `title` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '模块标题',
  `business_type` int NULL DEFAULT 0 COMMENT '业务类型(0其它 1新增 2修改 3删除 4登录 5登出 6录取 7未录取)',
  `method` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '方法名称',
  `request_method` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '请求方式',
  `operator_type` int NULL DEFAULT 0 COMMENT '操作类别(0提交题目 1录取 2判题)',
  `oper_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '操作人员的昵称',
  `dept_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '部门类型 (0新人 0技术部 1统筹部)',
  `oper_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '请求URL',
  `oper_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '主机地址',
  `oper_param` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '请求参数',
  `json_result` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '返回参数',
  `status` int NULL DEFAULT 0 COMMENT '操作状态(0正常 1异常)',
  `error_msg` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '错误消息',
  `oper_time` datetime NULL DEFAULT NULL COMMENT '操作时间',
  `cost_time` bigint NULL DEFAULT 0 COMMENT '消耗时间',
  PRIMARY KEY (`oper_id`) USING BTREE,
  INDEX `idx_sys_oper_log_bt`(`business_type` ASC) USING BTREE,
  INDEX `idx_sys_oper_log_s`(`status` ASC) USING BTREE,
  INDEX `idx_sys_oper_log_ot`(`oper_time` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 530 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '操作日志记录' ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

3.2 定义SysOperLogMapper

SysOperLogMapper com.tiantian.common.log.mapper

import com.tiantian.common.log.domain.SysOperLog;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

/**
 * 操作日志 数据层
 */
@Mapper
public interface SysOperLogMapper {

    /**
     * 插入日志数据
     */
    void insertOperLog(SysOperLog operLog);

    /**
     * 查询日志列表
     */
    List<SysOperLog> selectOperLogList(SysOperLog sysOperLog);

    /**
     * 批量删除系统操作日志
     *
     * @param operIds 需要删除的操作日志ID
     * @return 结果
     */
    int deleteOperLogByIds(Long[] operIds);

    /**
     * 根据operId查询日志数据
     * @param operId 日志主键
     */
    SysOperLog selectOperLogById(Long operId);

    /**
     * 清空操作日志
     */
    void cleanOperLog();
}

3.3 定义MapperXML

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tiantian.system.mapper.SysOperLogMapper">
  <!-- 结果集 -->
  <resultMap type="com.tiantian.system.domain.entity.SysOperLog" id="SysOperLogMap">
    <result property="operId" column="oper_id" jdbcType="INTEGER"/>
    <result property="title" column="title" jdbcType="VARCHAR"/>
    <result property="businessType" column="business_type" jdbcType="INTEGER"/>
    <result property="method" column="method" jdbcType="VARCHAR"/>
    <result property="requestMethod" column="request_method" jdbcType="VARCHAR"/>
    <result property="operatorType" column="operator_type" jdbcType="INTEGER"/>
    <result property="operName" column="oper_name" jdbcType="VARCHAR"/>
    <result property="operUrl" column="oper_url" jdbcType="VARCHAR"/>
    <result property="operIp" column="oper_ip" jdbcType="VARCHAR"/>
    <result property="operParam" column="oper_param" jdbcType="VARCHAR"/>
    <result property="jsonResult" column="json_result" jdbcType="VARCHAR"/>
    <result property="status" column="status" jdbcType="INTEGER"/>
    <result property="errorMsg" column="error_msg" jdbcType="VARCHAR"/>
    <result property="operTime" column="oper_time" jdbcType="TIMESTAMP"/>
    <result property="costTime" column="cost_time" jdbcType="INTEGER"/>
  </resultMap>

  <!-- 基本字段 -->
  <sql id="Base_Column_List">
      oper_id, title, business_type, method, request_method, operator_type, oper_name, oper_url, oper_ip, oper_param, json_result, status, error_msg, oper_time,cost_time
  </sql>

  <!-- 基本查询条件 -->
  <sql id="Query_Items">
    <if test="operId != null">
      and oper_id = #{operId}
    </if>
    <if test="title != null and title != ''">
      and title like concat('%', #{title}, '%')
    </if>
    <if test="businessType != null">
      and business_type = #{businessType}
    </if>
    <if test="method != null and method != ''">
      and method like concat('%', #{method}, '%')
    </if>
    <if test="requestMethod != null and requestMethod != ''">
      and request_method = #{requestMethod}
    </if>
    <if test="operatorType != null">
      and operator_type = #{operatorType}
    </if>
    <if test="operName != null and operName != ''">
      and oper_name like concat('%', #{operName}, '%')
    </if>
    <if test="operUrl != null and operUrl != ''">
      and oper_url = #{operUrl}
    </if>
    <if test="operIp != null and operIp != ''">
      and oper_ip like concat('%', #{operIp}, '%')
    </if>
    <if test="operParam != null and operParam != ''">
      and oper_param = #{operParam}
    </if>
    <if test="jsonResult != null and jsonResult != ''">
      and json_result = #{jsonResult}
    </if>
    <if test="status != null">
      and status = #{status}
    </if>
    <if test="errorMsg != null and errorMsg != ''">
      and error_msg = #{errorMsg}
    </if>
    <if test="operTime != null">
      and oper_time = #{operTime}
    </if>
  </sql>

  <!-- 查询单个-->
  <select id="selectOperLogById" resultMap="SysOperLogMap">
    select
    <include refid="Base_Column_List" />
    from sys_oper_log
    where oper_id = #{operId}
  </select>

  <!-- 通过实体作为筛选条件查询 -->
  <select id="selectOperLogList" resultMap="SysOperLogMap">
    select
    <include refid="Base_Column_List" />
    from sys_oper_log
    <where>
      <include refid="Query_Items" />
    </where>
    order by oper_time desc
  </select>

  <!-- 新增所有列 -->
  <insert id="insertOperLog" keyProperty="operId" useGeneratedKeys="true">
      insert into sys_oper_log(oper_id, title, business_type, method, request_method, operator_type, oper_name,
                               oper_url, oper_ip, oper_param, json_result, status, error_msg, oper_time, cost_time)
      values (#{operId}, #{title}, #{businessType}, #{method}, #{requestMethod}, #{operatorType}, #{operName},
              #{operUrl}, #{operIp}, #{operParam}, #{jsonResult}, #{status}, #{errorMsg},
              #{operTime}, #{costTime})
  </insert>


  <!-- 通过主键删除 -->
  <delete id="deleteById">
    delete from sys_oper_log where oper_id = #{operId}
  </delete>

  <!-- 根据ID批量删除 -->
  <delete id="deleteOperLogByIds" parameterType="Long">
    delete from sys_oper_log where oper_id in
    <foreach collection="array" item="operId" open="(" separator="," close=")">
      #{operId}
    </foreach>
  </delete>

  <!-- 清空表 -->
  <update id="cleanOperLog">
    truncate table sys_oper_log
  </update>

</mapper>

4. 核心定义切面类

其中用到了三个注解

  • @Before处理请求前执行,未到达controller
  • @AfterReturning处理完请求后执行
  • @AfterThrowing目标方法执行报错时执行, 但不会与@AfterReturning一起执行

其中Spring AOP所有注解的执行顺序如下

try{
    try{
        //@Around 开始
        //@Before
        method.invoke(..); // 目标方法执行
        //@Around 结束
    }finally{
        //@After
    }
    //@AfterReturning
}catch(){
    //@AfterThrowing
}

日志保存流程图如下
在这里插入图片描述

需要引入到的工具类 hutool

LogAspect 存放目录: com.tiantian.common.log.aspect

import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.http.HttpMethod;
import org.springframework.validation.BindingResult;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;

import java.util.*;

/**
 * 操作日志记录处理
 *
 * @author TianTian
 */
@Aspect
@AutoConfiguration
@RequiredArgsConstructor
public class LogAspect2 {
    
    private final SysOperLogMapper sysOperLogMapper;

    /**
     * 排除敏感属性字段
     */
    public static final String[] EXCLUDE_PROPERTIES = {"password", "oldPassword", "newPassword", "confirmPassword"};


    /**
     * 计算操作消耗时间
     */
    private static final ThreadLocal<Long> TIME_THREADLOCAL = new ThreadLocal<>();

    /**
     * 处理请求前执行
     */
    @Before(value = "@annotation(controllerLog)")
    public void boBefore(JoinPoint joinPoint, Log controllerLog) {
        TIME_THREADLOCAL.set(System.currentTimeMillis());
    }

    /**
     * 处理完请求后执行
     *
     * @param joinPoint 切点
     */
    @AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
    public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) {
        handleLog(joinPoint, controllerLog, null, jsonResult);
    }

    /**
     * 拦截异常操作
     *
     * @param joinPoint 切点
     * @param e         异常
     */
    @AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
    public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) {
        handleLog(joinPoint, controllerLog, e, null);
    }

    protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) {
        try {
            // *========数据库日志=========*//
            SysOperLog operLog = new SysOperLog();
            // 请求的地址
            operLog.setOperIp("可使用获取IP的工具");
            // 访问的URI
            HttpServletRequest request = getRequest();
            operLog.setOperUrl(request.getRequestURI());

            // LoginUser loginUser = LoginHelper.getLoginUser();
            // operLog.setOperName(loginUser.getNickName());
            operLog.setOperName("可以从userService中获取到登录用户名称");

            if (e != null) {
                // 设置状态为失败 0
                operLog.setStatus(0);
                operLog.setErrorMsg(e.getMessage());
            } else {
                // 设置状态为成功 1
                operLog.setStatus(1);
            }
            // 设置方法名称
            String className = joinPoint.getTarget().getClass().getName();
            String methodName = joinPoint.getSignature().getName();
            operLog.setMethod(className + "." + methodName + "()");
            // 设置请求方式
            operLog.setRequestMethod(request.getMethod());
            // 处理设置注解上的参数
            getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
            // 设置消耗时间(单位:毫秒)
            Long takeTime = System.currentTimeMillis() - TIME_THREADLOCAL.get();
            operLog.setCostTime(takeTime);
            // 保存到数据库
            sysOperLogMapper.insertOperLog(operLog);
        } catch (Exception exp) {
            // 记录本地异常日志
            exp.printStackTrace();
        } finally {
            TIME_THREADLOCAL.remove();
        }
    }

    private static HttpServletRequest getRequest() {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return attributes.getRequest();
    }

    /**
     * 获取注解中对方法的描述信息 用于Controller层注解
     *
     * @param log     日志
     * @param operLog 操作日志
     * @throws Exception
     */
    public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog, Object jsonResult) throws Exception {
        // 设置action动作
        operLog.setBusinessType(log.businessType().getNumber());
        // 设置标题
        operLog.setTitle(log.businessType().getDescription());
        // 设置操作人类别
        operLog.setOperatorType(log.operatorType().ordinal());
        // 是否需要保存request,参数和值
        if (log.isSaveRequestData()) {
            // 获取参数的信息,传入到数据库中。
            setRequestValue(joinPoint, operLog, log.excludeParamNames());
        }
        // 是否需要保存response,参数和值
        if (log.isSaveResponseData() && ObjectUtil.isNotNull(jsonResult)) {
            operLog.setJsonResult(jsonResult.toString());
        }
    }

    /**
     * 获取请求的参数,放到log中
     *
     * @param operLog 操作日志
     */
    private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog, String[] excludeParamNames) {
        HttpServletRequest request = getRequest();
        Map<String, String> paramsMap = new HashMap<>();
        for (Map.Entry<String, String[]> entry : getParams(request).entrySet()) {
            paramsMap.put(entry.getKey(), StrUtil.join(",", entry.getValue()));
        }
        String requestMethod = operLog.getRequestMethod();
        if (MapUtil.isEmpty(paramsMap)
                && HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
            String params = argsArrayToString(joinPoint.getArgs(), excludeParamNames);
            operLog.setOperParam(params);
        } else {
            MapUtil.removeAny(paramsMap, EXCLUDE_PROPERTIES);
            MapUtil.removeAny(paramsMap, excludeParamNames);
            operLog.setOperParam(paramsMap.toString());
        }
    }

    /**
     * 参数拼装
     */
    private String argsArrayToString(Object[] paramsArray, String[] excludeParamNames) {
        StringJoiner params = new StringJoiner(" ");
        if (ArrayUtil.isEmpty(paramsArray)) {
            return params.toString();
        }
        for (Object o : paramsArray) {
            if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) {
                String str = JSONUtil.toJsonStr(o);
                JSONObject dict = JSONUtil.parseObj(str);
                if (MapUtil.isNotEmpty(dict)) {
                    MapUtil.removeAny(dict, EXCLUDE_PROPERTIES);
                    MapUtil.removeAny(dict, excludeParamNames);
                    str = JSONUtil.toJsonStr(dict);
                }
                params.add(str);
            }
        }
        return params.toString();
    }

    /**
     * 获得所有请求参数
     *
     * @param request 请求对象{@link ServletRequest}
     * @return Map
     */
    public static Map<String, String[]> getParams(ServletRequest request) {
        final Map<String, String[]> map = request.getParameterMap();
        return Collections.unmodifiableMap(map);
    }

    /**
     * 判断是否需要过滤的对象。
     *
     * @param o 对象信息。
     * @return 如果是需要过滤的对象,则返回true;否则返回false。
     */
    @SuppressWarnings("rawtypes")
    public boolean isFilterObject(final Object o) {
        Class<?> clazz = o.getClass();
        if (clazz.isArray()) {
            return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
        } else if (Collection.class.isAssignableFrom(clazz)) {
            Collection collection = (Collection) o;
            for (Object value : collection) {
                return value instanceof MultipartFile;
            }
        } else if (Map.class.isAssignableFrom(clazz)) {
            Map map = (Map) o;
            for (Object value : map.values()) {
                return value instanceof MultipartFile;
            }
        }
        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
                || o instanceof BindingResult;
    }
}

5. 使用注解

在controller层中的方法上添加即可
在这里插入图片描述

在数据库中查看记录
在这里插入图片描述
在这里插入图片描述

当然也可以使用现成的框架, 功能更丰富: mouzt/mzt-biz-log: 支持Springboot,基于注解的可使用变量、可以自定义函数的通用操作日志组件 (github.com)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值