AOP记录操作日志并支持队列插入

业务介绍

通过aop切面技术,借助ApiOperation的value()值,记录用户的具体操作信息,比如
操作描述、请求参数、返回结果、请求结果、用户信息、操作时间、执行时长、异常信息等信息

日志表设计

CREATE TABLE `operation_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `operation_desc` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '操作描述',
  `request_param` longtext CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT '请求参数',
  `success` char(1) DEFAULT NULL COMMENT '0成功 1失败',
  `response_param` longtext CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT '返回参数',
  `user_id` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '操作员id',
  `user_name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '操作员名称',
  `method` varchar(512) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '操作方法',
  `url` varchar(512) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '请求url',
  `ip` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '请求ip',
  `create_time` datetime DEFAULT NULL COMMENT '操作时间',
  `execution_duration` bigint(20) DEFAULT NULL COMMENT '执行时长(ms)',
  `error_message` longtext COMMENT '异常信息',
  PRIMARY KEY (`id`),
  KEY `create_time` (`create_time`,`execution_duration`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='操作日志表';

AOP实现

采用环绕通知记录正常操作日志以及异常日志:

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.zyp.annotation.NoLog;
import com.zyp.exception.OperationLogException;
import com.zyp.model.entity.OperationLog;
import com.zyp.util.IDUtil;
import com.zyp.util.Result;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindingResult;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.web.multipart.MultipartFile;



/**
 * 切面处理类,操作日志异常日志记录处理
 * @author leishen
 */
@Aspect
@Component
@Slf4j
public class OperationLogAspect {

    /**
     * 异常信息最大长度
     */
    private static final Integer MAX_ERROR_LENGTH=1000;

    /**
     * 定义切入点
     */
    @Pointcut("execution(* com.zyp.controller..*.*(..))")
    //@Pointcut("@annotation(io.swagger.annotations.ApiOperation)")
    public void pointCut() {
    }

    /**
     * 环绕通知
     * @param joinPoint
     */
    @Around(value = "pointCut()")
    public Object saveOperationLog(ProceedingJoinPoint joinPoint){
        //返回值
        Object object=null;
        // 获取RequestAttributes
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = (HttpServletRequest) requestAttributes
                .resolveReference(RequestAttributes.REFERENCE_REQUEST);
        // 从切面织入点处通过反射机制获取织入点处的方法
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        // 获取切入点所在的方法
        Method method = signature.getMethod();
        //判断类上面有此注解,有的话则不记录操作日志
        NoLog noLog1 = method.getDeclaringClass().getAnnotation(NoLog.class);
        //判断方法上面有此注解,有的话则不记录操作日志
        NoLog noLog = method.getAnnotation(NoLog.class);
        if (noLog != null || noLog1!=null ) {
            try {
                object = joinPoint.proceed();
            }catch (Throwable throwable){
                log.error(throwable.getMessage(),throwable);
                ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
                String value;
                if (apiOperation != null) {
                    value=apiOperation.value()+"请求失败!";
                }else{
                    value="请求失败!";
                }
                object=new Result<>().error(value);
            }
        }else{
            //创建日志实体类
            OperationLog operationLog = new OperationLog();
            // 获取请求的方法名
            String methodName = method.getName();
            // 获取请求的类名
            String className = joinPoint.getTarget().getClass().getName();
            methodName = className + "." + methodName;
            // 请求方法
            operationLog.setMethod(methodName);
            //获取ApiOperation注解,要求每个方法都要有这个注解
            ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
            if (apiOperation!=null) {
                //操作描述
                String value = apiOperation.value();
                operationLog.setOperationDesc(value);
                // 请求参数
                String requestParam = getParam(joinPoint);
                operationLog.setRequestParam(requestParam);
                //用户id
                String userId = StringUtils.isNotEmpty(request.getHeader("userId")) ? request.getHeader("userId") : null;
                operationLog.setUserId(userId);
                //用户名称
                String userName = StringUtils.isNotEmpty(request.getHeader("userName")) ? request.getHeader("userName") : null;
                operationLog.setUserName(userName);
                // 主键ID
                operationLog.setId(IDUtil.getId());
                // 请求URI
                String requestURI = request.getRequestURI();
                operationLog.setUrl(requestURI);
                //设置ip
                operationLog.setIp(this.getIpAddress(request));
                // 创建时间
                operationLog.setCreateTime(new Date());
                try {
                    //目标方法执行开始
                    Long start = System.currentTimeMillis();
                    object = joinPoint.proceed();
                    //目标方法执行结束
                    Long end = System.currentTimeMillis();
                    operationLog.setSuccess("0");
                    //计算执行时长 保留两位小数
                    //BigDecimal executionDuration=calExecutionDuration(start,end);
                    operationLog.setExecutionDuration(end-start);
                    // 返回结果
                    operationLog.setResponseParam(JSON.toJSONString(object));
                }catch (Throwable throwable){
                    log.error(throwable.getMessage(),throwable);
                    //失败
                    operationLog.setSuccess("1");
                    if (throwable instanceof Exception) {
                        Exception e = (Exception) throwable;
                        operationLog.setErrorMessage(stackTraceToString(e.getClass().getName(), e.getMessage(), e.getStackTrace()));
                        throw new OperationLogException(value+"请求失败!");
                    }
                }finally {
                    //加入任务队列
                    LogTask.addTask(operationLog);
                }
            }
        }
        return object;
    }

    /**
     * 计算执行时长 保留两位小数
     * @param start
     * @param end
     * @return
     */
    private BigDecimal calExecutionDuration(Long start, Long end) {
        BigDecimal decimal = null;
        if (start!=null && end!=null) {
            long l = end - start;
            BigDecimal decimal1 = new BigDecimal(1000 + "");
            decimal= new BigDecimal(l + "").divide(decimal1, 2, RoundingMode.HALF_UP);
        }
        return decimal;
    }

    /**
     * 封装请求参数
     * @param joinPoint
     * @return
     */
    private String getParam(JoinPoint joinPoint) {
        List<Object> allArgs = Arrays.asList(joinPoint.getArgs());
        List<Object> args = allArgs.stream().map(arg -> {
            if (!(arg instanceof HttpServletRequest) && !(arg instanceof HttpServletResponse) 
            && !(arg instanceof BindingResult) && !(arg instanceof MultipartFile)) {
                return arg;
            } else {
                return null;
            }
        }).filter(arg -> arg != null).collect(Collectors.toList());
        if (CollectionUtils.isNotEmpty(args)) {
            return JSON.toJSONStringWithDateFormat(args.get(0),"yyyy-MM-dd", SerializerFeature.WriteDateUseDateFormat);
        }else{
            return null;
        }
    }

    /**
     * 转换异常信息为字符串
     *
     * @param exceptionName    异常名称
     * @param exceptionMessage 异常信息
     * @param elements         堆栈信息
     */
    public static String stackTraceToString(String exceptionName, String exceptionMessage, StackTraceElement[] elements) {
        StringBuffer stringBuffer = new StringBuffer();
        for (StackTraceElement stet : elements) {
            stringBuffer.append(stet + "\n");
        }
        String message = exceptionName + ":" + exceptionMessage + "\r\n" + stringBuffer;
        if (message.length()>MAX_ERROR_LENGTH) {
            message=message.substring(0,MAX_ERROR_LENGTH);
        }
        return message;
    }

    /**
     * 获取访问者的IP
     */
    private String getIpAddress(HttpServletRequest request) {
        String ip = null;
        // X-Forwarded-For:Squid 服务代理
        String ipAddresses = request.getHeader("X-Forwarded-For");
        if (ipAddresses == null || ipAddresses.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
            // Proxy-Client-IP:apache 服务代理
            ipAddresses = request.getHeader("Proxy-Client-IP");
        }
        if (ipAddresses == null || ipAddresses.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
            // WL-Proxy-Client-IP:weblogic 服务代理
            ipAddresses = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ipAddresses == null || ipAddresses.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
            // HTTP_CLIENT_IP:有些代理服务器
            ipAddresses = request.getHeader("HTTP_CLIENT_IP");
        }
        if (ipAddresses == null || ipAddresses.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
            // X-Real-IP:nginx服务代理
            ipAddresses = request.getHeader("X-Real-IP");
        }
        // 有些网络通过多层代理,那么获取到的ip就会有多个,一般都是通过逗号(,)分割开来,并且第一个ip为客户端的真实IP
        if (ipAddresses != null && ipAddresses.length() != 0) {
            ip = ipAddresses.split(",")[0];
        }
        // 还是不能获取到,最后再通过request.getRemoteAddr();获取
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ipAddresses)) {
            ip = request.getRemoteAddr();
        }
        return ip.equals("0:0:0:0:0:0:0:1") ? "127.0.0.1" : ip;
    }
}

队列批量插入日志

import com.google.common.collect.Lists;
import com.zyp.model.entity.OperationLog;
import com.zyp.service.OperationLogService;
import com.zyp.util.SpringContextUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;

/**
 * @author leishen
 */
@Component
@Slf4j
public class LogTask implements Runnable {

    private static final ConcurrentLinkedQueue<OperationLog> QUEUE=new ConcurrentLinkedQueue<>();

    /**
     * 添加任务
     * @param operationLog
     */
    public static void addTask(OperationLog operationLog){
        QUEUE.offer(operationLog);
    }

    @Override
    public void run() {
        //最大数量
        int batchSize=200;
        while (true){
            List<OperationLog> taskList= Lists.newArrayList();
            //若队列有数据
            if (!QUEUE.isEmpty()) {
                //如果队列中的数据小于200,就等待10s
                if (QUEUE.size()<200) {
                    try {
                        Thread.sleep(10000);
                    } catch (InterruptedException e) {
                        log.error(e.getMessage());
                    }
                }
                //开始时间
                LocalDateTime start = LocalDateTime.now();
                try {
                    log.info("日志表插入开始,当前时间{},当前队列条数{}", start, QUEUE.size());
                    //队列非空且小于200加入任务
                    while (!QUEUE.isEmpty() && taskList.size() < batchSize){
                        taskList.add(QUEUE.poll());
                    }
                    LocalDateTime end = LocalDateTime.now();
                    log.info("从队列取出数据,当前时间{},取出条数{}条,耗时{}s", end, taskList.size(), Duration.between(start, end).getSeconds());
                    OperationLogService operationLogService = SpringContextUtils.getBean(OperationLogService.class);
                    operationLogService.saveBatch(taskList);
                    end = LocalDateTime.now();
                    log.info("日志表插入结束,当前时间{},插入条数{}条,耗时{}s", end, taskList.size(), Duration.between(start, end).getSeconds());
                    taskList.clear();
                }catch (Exception e){
                    log.error(e.getMessage(),e);
                    OperationLogService operationLogService = SpringContextUtils.getBean(OperationLogService.class);
                    taskList.forEach(i->{
                        operationLogService.save(i);
                    });
                    taskList.clear();
                    LocalDateTime end = LocalDateTime.now();
                    log.info("日志表插入结束,当前时间{},插入条数{}条,耗时{}s", end, taskList.size(), Duration.between(start, end).getSeconds());
                }
            }else{
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    log.error(e.getMessage());
                }
            }
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                log.error(e.getMessage());
            }
        }
    }
}

不记录日志的实现

通过自定义注解NoLog,若方法上面有此注解,该方法执行则不记录日志;若类有此注解,则该类的里面的方法执行都不记录日志

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 不记录日志到注解
 * @author leishen
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD,ElementType.TYPE})
public @interface NoLog {
}

启动记录日志的线程

import com.zyp.aspect.LogTask;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

/**
 * 在项目启动完毕后做一些动作
 * @author leishen
 */
@Component
@Order(1)
public class Runner implements ApplicationRunner {

    /**
     * @param args 此处的项目启动参数是经过处理的
     * @throws Exception
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {
        //启动记录日志的线程
        new Thread(new LogTask()).start();
    }
}

自定义日志异常

import lombok.Data;

/**
 * 自定义记录日志异常
 * @author leishen
 *
 * 继承RuntimeException而不是Exception,避免抛出UndeclaredThrowableException
 */
@Data
public class OperationLogException extends RuntimeException{

    private String desc;

    private Exception exception;

    /**
     * 自定义异常信息
     * @param desc
     */
    public OperationLogException(String desc) {
        this.desc=desc;
    }

    /**
     * 自定义异常信息
     * @param desc
     */
    public OperationLogException(String desc,Exception exception) {
        this.desc=desc;
        this.exception=exception;
    }
}

全局处理异常封装结果

import com.zyp.util.Result;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.validator.HibernateValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.validation.ConstraintViolationException;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.stream.Collectors;

/**
 * 定义全局处理异常
 * @author leishen
 */
@RestControllerAdvice
@Slf4j
@Configuration
public class ExceptionHandlerConfig {

    /**
     * 全局处理异常
     * @param e
     * @return
     */
    @ExceptionHandler(Exception.class)
    public Result handlerException(Exception e){
        log.error(e.getMessage());
        return new Result().error(e.getMessage());
    }


    /**
     * 全局处理异常
     * @param e
     * @return
     */
    @ExceptionHandler(OperationLogException.class)
    public Object handlerException(OperationLogException e){
        return new Result().error(e.getDesc());
    }

    /**
     * 处理 form data方式调用接口校验失败抛出的异常
     * @param e
     * @return
     */
    @ExceptionHandler(BindException.class)
    public Result handlerException(BindException e){
        BindingResult bindingResult = e.getBindingResult();
        String result = bindingResult.getFieldErrors().stream().map(o -> o.getDefaultMessage()).collect(Collectors.joining(","));
        return new Result().error(result);
    }

    /**
     * 处理 json 请求体调用接口校验失败抛出的异常
     * @param e
     * @return
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handlerException(MethodArgumentNotValidException e){
        BindingResult bindingResult = e.getBindingResult();
        String result = bindingResult.getFieldErrors().stream().map(o -> o.getDefaultMessage()).collect(Collectors.joining(","));
        return new Result().error(result);
    }

    /**
     * 处理requestParam/PathVariable参数校验的异常
     * @param e
     * @return
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public Result handlerException(ConstraintViolationException e){
        String result = e.getConstraintViolations().stream().map(o -> o.getMessage()).collect(Collectors.joining(","));
        return new Result().error(result);
    }

    /**
     * spring Validation默认校验完所有参数才会返回结果,可将failFast设置为true,一旦校验失败就会结果校验并返回结果
     * @return
     */
    @Bean
    public Validator validator(){
        ValidatorFactory factory= Validation.byProvider(HibernateValidator.class).
                configure().failFast(false).buildValidatorFactory();
        return factory.getValidator();
    }
}

其他类

结果封装类:

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;

import java.io.Serializable;

/** 响应数据
 *
 * @author Mark sunlightcs@gmail.com
 * @since 1.0.0
 */
@ApiModel(value = "响应")
public class Result<T> implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * 编码:0表示成功,其他值表示失败
     */
    @ApiModelProperty(value = "编码:200表示成功,其他值表示失败")
    private int code = 0;
    /**
     * 消息内容
     */
    @ApiModelProperty(value = "消息内容")
    private String msg = "success";
    /**
     * 响应数据
     */
    @ApiModelProperty(value = "响应数据")
    private T data;

    public Result<T> ok(T data) {
        this.setData(data);
        return this;
    }

    public Result<T> ok(T data,String msg){
        this.setData(data);
        this.setMsg(msg);
        return this;
    }

    public boolean success(){
        return code == 0 ? true : false;
    }

    public Result<T> error() {
        this.code = 500;
        this.msg = "失败";
        return this;
    }

    public Result<T> error(int code) {
        this.code = code;
        this.msg = "失败";
        return this;
    }

    public Result<T> error(int code, String msg) {
        this.code = code;
        this.msg = msg;
        return this;
    }

    public Result<T> error(String msg) {
        this.code = 500;
        this.msg = msg;
        return this;
    }

    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

日志实体类:

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;

import java.util.Date;

/**
 * <p>
 * 操作日志表
 * </p>
 *
 * @author syl
 * @since 2023-01-30
 */
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value="OperationLog对象", description="操作日志表")
public class OperationLog{

    private static final long serialVersionUID = 1L;

    @ApiModelProperty(value = "主键")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @ApiModelProperty(value = "操作描述")
    private String operationDesc;

    @ApiModelProperty(value = "请求参数")
    private String requestParam;

    @ApiModelProperty(value = "0成功 1失败")
    private String success;

    @ApiModelProperty(value = "返回参数")
    private String responseParam;

    @ApiModelProperty(value = "操作员id")
    private String userId;

    @ApiModelProperty(value = "操作员名称")
    private String userName;

    @ApiModelProperty(value = "操作方法")
    private String method;

    @ApiModelProperty(value = "请求url")
    private String url;

    @ApiModelProperty(value = "请求ip")
    private String ip;

    @ApiModelProperty(value = "操作时间")
    private Date createTime;

    @ApiModelProperty(value = "执行时长(ms)")
    private Long executionDuration;

    @ApiModelProperty(value = "异常信息")
    private String errorMessage;
}

mapper层

import com.zyp.model.entity.OperationLog;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * <p>
 * 操作日志表 Mapper 接口
 * </p>
 *
 * @author syl
 * @since 2023-01-30
 */
public interface OperationLogMapper extends BaseMapper<OperationLog> {

}

mapper.xml

<?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.zyp.mapper.OperationLogMapper">

    <!-- 通用查询映射结果 -->
    <resultMap id="BaseResultMap" type="com.zyp.model.entity.OperationLog">
        <id column="id" property="id" />
        <result column="operation_desc" property="operationDesc" />
        <result column="request_param" property="requestParam" />
        <result column="success" property="success" />
        <result column="response_param" property="responseParam" />
        <result column="user_id" property="userId" />
        <result column="user_name" property="userName" />
        <result column="method" property="method" />
        <result column="url" property="url" />
        <result column="ip" property="ip" />
        <result column="create_time" property="createTime" />
        <result column="execution_duration" property="executionDuration" />
        <result column="error_message" property="errorMessage" />
    </resultMap>

    <!-- 通用查询结果列 -->
    <sql id="Base_Column_List">
        id, operation_desc, request_param, success, response_param, user_id, user_name, method, url, ip, create_time, execution_duration, error_message
    </sql>

</mapper>

service层

import com.zyp.model.entity.OperationLog;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 * 操作日志表 服务类
 * </p>
 *
 * @author syl
 * @since 2023-01-30
 */
public interface OperationLogService extends IService<OperationLog> {

}

service实现类

import com.zyp.model.entity.OperationLog;
import com.zyp.mapper.OperationLogMapper;
import com.zyp.service.OperationLogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

/**
 * <p>
 * 操作日志表 服务实现类
 * </p>
 *
 * @author syl
 * @since 2023-01-30
 */
@Service
public class OperationLogServiceImpl extends ServiceImpl<OperationLogMapper, OperationLog> implements OperationLogService {

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值