log4j2 java日志脱敏,已写好json和xml脱敏正则,金融项目日志脱敏。

注:日志脱敏经过大量上线项目测试,脱敏平均时间在30毫秒左右,短数据基本不占用时间;另外大数据超量数据耗损时间请关注自己程序日志打印优化和对象转json耗时,或log4j异步打印,请移步百度。使用前请@Test测试。

2019-09-23   有朋友私信我需要AppLogger的代码,我补充到下面第4点。

 

1.引入log4j2的jar包:log4j-api和log4j-core。

2.自定义脱敏类和方法,重写PatternLayout类,自定义日志格式和脱敏正则表达式。

import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.config.plugins.PluginElement;
import org.apache.logging.log4j.core.config.plugins.PluginFactory;
import org.apache.logging.log4j.core.pattern.RegexReplacement;
import org.apache.logging.log4j.status.StatusLogger;

/**
 * 自定义标签replaces, 用于多个正则表达式替换
 * 
 * @author zhangyanchun
 * @date 2017-07-27
 */
@Plugin(name = "replaces", category = "Core", printObject = true)
public final class CustomRegexReplaces {

	private static final Logger LOGGER = StatusLogger.getLogger();

	// replace标签,复用log4j已有plugin, replaces 下可以0,1,多个replace
	private final RegexReplacement[] replaces;

	private CustomRegexReplaces(RegexReplacement[] replaces) {
		this.replaces = replaces;
	}

	/**
	 * 格式化输出日志信息, 此方法会执行多个正则表达式匹配与替换
	 * 
	 * @param msg
	 * @return
	 */
	public String format(String msg) {
		for (RegexReplacement replace : replaces) {
			msg = replace.format(msg);
		}
		return msg;
	}

	/**
	 * 实现pluginFactory, 用于生成pugin
	 * 
	 * @param replaces
	 * @return
	 */
	@PluginFactory
	public static CustomRegexReplaces createRegexReplacement(
			@PluginElement("replaces") final RegexReplacement[] replaces) {
		if (replaces == null) {
			LOGGER.info("no replaces is defined");
			return null;
		}
		if (replaces.length == 0) {
			LOGGER.warn("have the replaces , but no replace is set");
			return null;
		}
		return new CustomRegexReplaces(replaces);
	}

}
 
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.logging.log4j.core.Layout;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.DefaultConfiguration;
import org.apache.logging.log4j.core.config.Node;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
import org.apache.logging.log4j.core.config.plugins.PluginBuilderAttribute;
import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory;
import org.apache.logging.log4j.core.config.plugins.PluginConfiguration;
import org.apache.logging.log4j.core.config.plugins.PluginElement;
import org.apache.logging.log4j.core.config.plugins.PluginFactory;
import org.apache.logging.log4j.core.layout.AbstractStringLayout;
import org.apache.logging.log4j.core.layout.PatternLayout;
import org.apache.logging.log4j.core.pattern.LogEventPatternConverter;
import org.apache.logging.log4j.core.pattern.PatternFormatter;
import org.apache.logging.log4j.core.pattern.PatternParser;

/**
 * 自定义log4j2 layout, 扩展自PatternLayout(拷贝自log4j2, 留待以后扩展使用)
 * 
 * @author zhangyanchun
 * @date 2017-07-27
 */
@Plugin(name = "CustomPatternLayout", category = Node.CATEGORY, elementType = Layout.ELEMENT_TYPE, printObject = true)
public final class CustomPatternLayout extends AbstractStringLayout {

	private static final long serialVersionUID = 1L;

	// 默认格式化
	public static final String DEFAULT_CONVERSION_PATTERN = "%m%n";

	// ttc 默认格式化
	public static final String TTCC_CONVERSION_PATTERN = "%r [%t] %p %c %x - %m%n";

	// 简单格式化
	public static final String SIMPLE_CONVERSION_PATTERN = "%d [%t] %p %c - %m%n";

	public static final String KEY = "Converter";

	// 支持多个formater
	private final List<PatternFormatter> formatters;

	private final String conversionPattern;

	private final Configuration config;

	private final CustomRegexReplaces replace;

	private final boolean alwaysWriteExceptions;

	private final boolean noConsoleNoAnsi;

	/**
	 * 构造自动以patternLayout
	 * 
	 * @param config
	 * @param replace
	 * @param pattern
	 * @param charset
	 * @param alwaysWriteExceptions
	 * @param noConsoleNoAnsi
	 * @param header
	 * @param footer
	 */
	private CustomPatternLayout(final Configuration config, final CustomRegexReplaces replace, final String pattern,
			final Charset charset, final boolean alwaysWriteExceptions, final boolean noConsoleNoAnsi,
			final String header, final String footer) {
		super(charset, toBytes(header, charset), toBytes(footer, charset));
		this.replace = replace;
		this.conversionPattern = pattern;
		this.config = config;
		this.alwaysWriteExceptions = alwaysWriteExceptions;
		this.noConsoleNoAnsi = noConsoleNoAnsi;
		final PatternParser parser = createPatternParser(config);
		this.formatters = parser.parse(pattern == null ? DEFAULT_CONVERSION_PATTERN : pattern,
				this.alwaysWriteExceptions, this.noConsoleNoAnsi);
	}

	private static byte[] toBytes(final String str, final Charset charset) {
		if (str != null) {
			return str.getBytes(charset != null ? charset : Charset.defaultCharset());
		}
		return null;
	}

	private byte[] strSubstitutorReplace(final byte... b) {
		if (b != null && config != null) {
			return getBytes(config.getStrSubstitutor().replace(new String(b, getCharset())));
		}
		return b;
	}

	@Override
	public byte[] getHeader() {
		return strSubstitutorReplace(super.getHeader());
	}

	@Override
	public byte[] getFooter() {
		return strSubstitutorReplace(super.getFooter());
	}

	public String getConversionPattern() {
		return conversionPattern;
	}

	
	@Override
	public Map<String, String> getContentFormat() {
		final Map<String, String> result = new HashMap<String, String>();
		result.put("structured", "false");
		result.put("formatType", "conversion");
		result.put("format", conversionPattern);
		return result;
	}

	
	@Override
	public String toSerializable(final LogEvent event) {
		final StringBuilder buf = new StringBuilder();
		for (final PatternFormatter formatter : formatters) {
			formatter.format(event, buf);
		}
		String str = buf.toString();
		if (replace != null) {
			str = replace.format(str);
		}
		return str;
	}

	/**
	 * pattern parser
	 * @param config
	 * @return
	 */
	public static PatternParser createPatternParser(final Configuration config) {
		if (config == null) {
			return new PatternParser(config, KEY, LogEventPatternConverter.class);
		}
		PatternParser parser = config.getComponent(KEY);
		if (parser == null) {
			parser = new PatternParser(config, KEY, LogEventPatternConverter.class);
			config.addComponent(KEY, parser);
			parser = (PatternParser) config.getComponent(KEY);
		}
		return parser;
	}

	@Override
	public String toString() {
		return conversionPattern;
	}

	/**
	 * log4j2 拷贝代码
	 * Create a pattern layout.
	 *
	 * @param pattern
	 *            The pattern. If not specified, defaults to
	 *            DEFAULT_CONVERSION_PATTERN.
	 * @param config
	 *            The Configuration. Some Converters require access to the
	 *            Interpolator.
	 * @param replace
	 *            A Regex replacement String.
	 * @param charset
	 *            The character set.
	 * @param alwaysWriteExceptions
	 *            If {@code "true"} (default) exceptions are always written even
	 *            if the pattern contains no exception tokens.
	 * @param noConsoleNoAnsi
	 *            If {@code "true"} (default is false) and
	 *            {@link System#console()} is null, do not output ANSI escape
	 *            codes
	 * @param header
	 *            The footer to place at the top of the document, once.
	 * @param footer
	 *            The footer to place at the bottom of the document, once.
	 * @return The PatternLayout.
	 */
	@PluginFactory
	public static CustomPatternLayout createLayout(
			@PluginAttribute(value = "pattern", defaultString = DEFAULT_CONVERSION_PATTERN) final String pattern,
			@PluginConfiguration final Configuration config,
			@PluginElement("Replaces") final CustomRegexReplaces replace,
			@PluginAttribute(value = "charset", defaultString = "UTF-8") final Charset charset,
			@PluginAttribute(value = "alwaysWriteExceptions", defaultBoolean = true) final boolean alwaysWriteExceptions,
			@PluginAttribute(value = "noConsoleNoAnsi", defaultBoolean = false) final boolean noConsoleNoAnsi,
			@PluginAttribute("header") final String header, @PluginAttribute("footer") final String footer) {
		return newBuilder().withPattern(pattern).withConfiguration(config).withRegexReplacement(replace)
				.withCharset(charset).withAlwaysWriteExceptions(alwaysWriteExceptions)
				.withNoConsoleNoAnsi(noConsoleNoAnsi).withHeader(header).withFooter(footer).build();
	}

	/**
	 * Creates a PatternLayout using the default options. These options include
	 * using UTF-8, the default conversion pattern, exceptions being written,
	 * and with ANSI escape codes.
	 *
	 * @return the PatternLayout.
	 * @see #DEFAULT_CONVERSION_PATTERN Default conversion pattern
	 */
	public static CustomPatternLayout createDefaultLayout() {
		return newBuilder().build();
	}

	/**
	 * Creates a builder for a custom PatternLayout.
	 * 
	 * @return a PatternLayout builder.
	 */
	@PluginBuilderFactory
	public static Builder newBuilder() {
		return new Builder();
	}

	/**
	 * Custom PatternLayout builder. Use the {@link PatternLayout#newBuilder()
	 * builder factory method} to create this.
	 */
	public static class Builder implements org.apache.logging.log4j.core.util.Builder<CustomPatternLayout> {

		// FIXME: it seems rather redundant to repeat default values (same goes
		// for field names)
		// perhaps introduce a @PluginBuilderAttribute that has no values of its
		// own and uses reflection?

		@PluginBuilderAttribute
		private String pattern = CustomPatternLayout.DEFAULT_CONVERSION_PATTERN;

		@PluginConfiguration
		private Configuration configuration = null;

		@PluginElement("Replaces")
		private CustomRegexReplaces regexReplacement = null;

		// LOG4J2-783 use platform default by default
		@PluginBuilderAttribute
		private Charset charset = Charset.defaultCharset();

		@PluginBuilderAttribute
		private boolean alwaysWriteExceptions = true;

		@PluginBuilderAttribute
		private boolean noConsoleNoAnsi = false;

		@PluginBuilderAttribute
		private String header = null;

		@PluginBuilderAttribute
		private String footer = null;

		private Builder() {
		}

		// TODO: move javadocs from PluginFactory to here

		public Builder withPattern(final String pattern) {
			this.pattern = pattern;
			return this;
		}

		public Builder withConfiguration(final Configuration configuration) {
			this.configuration = configuration;
			return this;
		}

		public Builder withRegexReplacement(final CustomRegexReplaces regexReplacement) {
			this.regexReplacement = regexReplacement;
			return this;
		}

		public Builder withCharset(final Charset charset) {
			this.charset = charset;
			return this;
		}

		public Builder withAlwaysWriteExceptions(final boolean alwaysWriteExceptions) {
			this.alwaysWriteExceptions = alwaysWriteExceptions;
			return this;
		}

		public Builder withNoConsoleNoAnsi(final boolean noConsoleNoAnsi) {
			this.noConsoleNoAnsi = noConsoleNoAnsi;
			return this;
		}

		public Builder withHeader(final String header) {
			this.header = header;
			return this;
		}

		public Builder withFooter(final String footer) {
			this.footer = footer;
			return this;
		}

		@Override
		public CustomPatternLayout build() {
			// fall back to DefaultConfiguration
			if (configuration == null) {
				configuration = new DefaultConfiguration();
			}
			return new CustomPatternLayout(configuration, regexReplacement, pattern, charset, alwaysWriteExceptions,
					noConsoleNoAnsi, header, footer);
		}
	}
}


注:闲来无事,今天(2018-06-19)看了一下博文,发现csdn的代码插入器,把html/xml的转义字符自动给修改了,我嘞个擦,而且插入的代码还不方便修改,草草草草草,下面replace正则表达式中,请把'<'换成'&lt;',把'"'换成'&quot;',更换完之后如下面插图。

3.本地文件显示和控制台显示脱敏日志,需要了解root和logger的区别,configuration中packages的功能,另外理解日志文件大小分包,理解日志格式编写。以下正则表达式包括了对手机号、身份证、姓名、银行卡进行脱敏,包含xml格式和json格式,其中json脱敏对多/"也进行了匹配。

 

<?xml version="1.0" encoding="UTF-8"?>
<configuration  status="OFF" packages="com.vc.strong">
	<appenders>
		<Console name="Console" target="SYSTEM_OUT">
			<CustomPatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%traceId] [%t] %-5level %logger{1.} - %msg%n ">
				<replaces>
					<replace regex="(<IdNo>|<CertId>|<CertID>)(\d{3})\d{11,14}(\w{1}</)" replacement="$1$2**************$3" />
					<replace regex="(<UserId>|<FullName>|<UserName>|<AcName>|<CifName>)([\u4E00-\u9FA5]{1})[\u4E00-\u9FA5]{1,}(</)" replacement="$1$2**$3" />
					<replace regex="(<MobilePhone>|<BankBindPhone>|<MobileTelephone>|<FamilyTel>)(\d{3})\d{4}(\d{4}</)" replacement="$1$2****$3" />
					<replace regex="(<AcNo>|<MyBankAccount>|<LoanAccountNo>|<BackAccountno>|<EAcNo>)(\d{3})\d{10,13}(\d{3}</)" replacement="$1$2*************$3" />
					<replace regex="(Phone|mobilePhone|phone|familyTel|holderMobile|mobileTelephone|bankBindPhone|holdermobile)(\\*":\\*")(\d{3})\d{4}(\d{4}\\*")" replacement="$1$2$3****$4" />
					<replace regex="(id_card_no|idCardNo|holderIdNo|holder_id_no|idNo|certId|idCard|holderidno|certID)(\\*":\\*")(\d{3})\d{11,14}(\w{1}\\*")" replacement="$1$2$3**************$4" />
					<replace regex="(name_pingyin|namePingyin|accountName|account_name|fullName|userId|realName)(\\*":\\*")([\u4E00-\u9FA5]{1})([\u4E00-\u9FA5]{1,})(\\*")" replacement="$1$2$3**$5" />
					<replace regex="(card_no|cardNo|acNo)(\\*":\\*")(\d{3})\d{10,13}(\d{3}\\*")" replacement="$1$2$3*************$4" />
				</replaces>
			</CustomPatternLayout>
		</Console>
		<RollingFile name="RollingFile" fileName="logs/vcstrong/vcstrong.log" filePattern="logs/vcstrong/vcstrong.log.%i">
			<CustomPatternLayout pattern="[%d{yyyy-MM-dd HH:mm:ss.SSS}][%p] [%traceId] [%t|%logger{1.}] - %msg%n">
				<replaces>
					<replace regex="(<IdNo>|<CertId>|<CertID>)(\d{3})\d{11,14}(\w{1}</)" replacement="$1$2**************$3" />
					<replace regex="(<UserId>|<FullName>|<UserName>|<AcName>|<CifName>)([\u4E00-\u9FA5]{1})[\u4E00-\u9FA5]{1,}(</)" replacement="$1$2**$3" />
					<replace regex="(<MobilePhone>|<BankBindPhone>|<MobileTelephone>|<FamilyTel>)(\d{3})\d{4}(\d{4}</)" replacement="$1$2****$3" />
					<replace regex="(<AcNo>|<MyBankAccount>|<LoanAccountNo>|<BackAccountno>|<EAcNo>)(\d{3})\d{10,13}(\d{3}</)" replacement="$1$2*************$3" />
					<replace regex="(Phone|mobilePhone|phone|familyTel|holderMobile|mobileTelephone|bankBindPhone|holdermobile)(\\*":\\*")(\d{3})\d{4}(\d{4}\\*")" replacement="$1$2$3****$4" />
					<replace regex="(id_card_no|idCardNo|holderIdNo|holder_id_no|idNo|certId|idCard|holderidno|certID)(\\*":\\*")(\d{3})\d{11,14}(\w{1}\\*")" replacement="$1$2$3**************$4" />
					<replace regex="(name_pingyin|namePingyin|accountName|account_name|fullName|userId|realName)(\\*":\\*")([\u4E00-\u9FA5]{1})([\u4E00-\u9FA5]{1,})(\\*")" replacement="$1$2$3**$5" />
					<replace regex="(card_no|cardNo|acNo)(\\*":\\*")(\d{3})\d{10,13}(\d{3}\\*")" replacement="$1$2$3*************$4" />
				</replaces>
			</CustomPatternLayout>
			<Policies>
				<SizeBasedTriggeringPolicy size="512 MB"/>
			</Policies>
			<DefaultRolloverStrategy max="20"/>
		</RollingFile>
	</appenders>

	<loggers>
		<logger name="com.vc.strong" level="DEBUG" additivity="false">
			<appender-ref ref="Console" />
			<appender-ref ref="RollingFile" />
		</logger>
		<root level="DEBUG">
			<appender-ref ref="Console" />
		</root>
	</loggers>

</configuration>

 

4.AppLogger日志打印工具类代码:

import java.io.ByteArrayOutputStream;
import java.io.PrintWriter;
import java.io.Serializable;
import java.util.Date;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import com.alibaba.fastjson.JSON;
import com.mxdx.edu.common.DateFormatUtils;
import com.mxdx.edu.common.DateStyle;

/**
 * 日志工具类
 */
public class AppLogger implements Serializable{
 
    private static final long serialVersionUID = 1L;
    
    private Logger logger;
    

    public AppLogger(Class<?> c) {
        this.logger = LogManager.getLogger(c);
    }
    
    public AppLogger(String name) {
        this.logger = LogManager.getLogger(name);
    }
    
    /**
     * 确定输出一条info信息,用于方法执行中间记录执行信息
     * @param msg
     */
    public void doInfo(String msg, Object data) {
        if(data != null) {
            if(data instanceof String) {
                logger.info(String.format("msg: %s ## data: %s", msg, data));
            } else {
                logger.info(String.format("msg: %s ## data: %s", msg, JSON.toJSONString(data)));
            }
            
        } else {
            logger.info(String.format("msg: %s", msg));
        }
        
    }
    
    /**
     * 确定输出一条错误信息, 用于方法执行中间记录执行错误信息, 用于方法内部单独使用错误输出的设置。
     * @param errorMsg
     * @param e
     */
    public void doError(String errorMsg, Throwable e) {
        logger.error(String.format("errorMsg: %s ## ex: %s", errorMsg, getExceptionTrace(e)));
    }
    
    /**
     * 确定输出一条错误信息, 用于方法执行中间记录执行错误信息, 用于方法内部单独使用错误输出的设置。
     * @param errorMsg
     * @param data
     * @param e
     */
    public void doError(String errorMsg, Object data, Throwable e) {
        logger.error(String.format("errorMsg: %s ## data: %s ## ex: %s", errorMsg, (data == null) ? "" : JSON.toJSONString(data), getExceptionTrace(e)));
    }
    
    /**
     * 确定输出一条错误信息, 用于方法执行中间记录执行错误信息, 用于方法内部单独使用错误输出的设置。
     * @param errorMsg
     * @param data
     */
    public void doError(String errorMsg, Object data) {
        logger.error(String.format("errorMsg: %s ## data: %s", errorMsg, (data == null) ? "" : JSON.toJSONString(data)));
    }

    /**
     * 日志开始方法,在方法执行的开始位置必须标注;不使用log4j获取方法信息,因为这个方法会影响性能
     * 
     * @param method
     */
    public void entry(String cip, String method) {
        LogModel<Object> lm = new LogModel<Object>();
        lm.setMethod(method);
        lm.setCip(cip);
        long startT = System.currentTimeMillis();
        lm.setCt(startT);
        SysLogModelContext.setlm(lm);
    }

    /**
     * 记录系统日志
     * 
     * @param msg
     */
    public void info(Object msg) {
        LogModel<Object> lm = SysLogModelContext.getLm();
        // lm.setService(logger.getName());
        
        String method = lm.getMethod();
        if (method == null) {
            lm.setMethod("no log entry");
        }
        lm.setData(msg);
        long startT = lm.getCt();
        if (startT > 0) {
            long endT = System.currentTimeMillis();
            lm.setT(DateFormatUtils.DateToString(new Date(startT),
                    DateStyle.YYYY_MM_DD_HH_MM_SS_SSS));
            lm.setCt((int) (endT - startT));
        }
        logger.info(JSON.toJSON(lm));
        SysLogModelContext.removeLm();
    }
    
    /**
     * 记录系统日志
     * 
     * @param msg
     */
    public void debug(Object msg) {
        LogModel<Object> lm = SysLogModelContext.getLm();
        // lm.setService(logger.getName());
        
        String method = lm.getMethod();
        if (method == null) {
            lm.setMethod("no log entry");
        }
        lm.setData(msg);
        long startT = lm.getCt();
        if (startT > 0) {
            long endT = System.currentTimeMillis();
            lm.setT(DateFormatUtils.DateToString(new Date(startT),
                    DateStyle.YYYY_MM_DD_HH_MM_SS_SSS));
            lm.setCt((int) (endT - startT));
        }
        logger.debug(JSON.toJSON(lm));
        SysLogModelContext.removeLm();
    }

    /**
     * 写错误信息
     * 
     * @param msg
     * @param e
     */
    public void error(Object msg, Throwable e) {
        LogModel<Object> lm = SysLogModelContext.getLm();
        String method = lm.getMethod();
        if (method == null) {
            lm.setMethod("no log entry");
        }

        lm.setData(msg);
        lm.setEx(getExceptionTrace(e));
        long startT = lm.getCt();
        if (startT > 0) {
            long endT = System.currentTimeMillis();
            lm.setT(DateFormatUtils.DateToString(new Date(startT),
                    DateStyle.YYYY_MM_DD_HH_MM_SS_SSS));
            lm.setCt((int) (endT - startT));
        }
        logger.error(JSON.toJSON(lm));
        SysLogModelContext.removeLm();
       
    }
    
    
    /**
     * 写错误信息
     * 
     * @param msg
     */
    public void error(Object msg) {
        LogModel<Object> lm = SysLogModelContext.getLm();
        String method = lm.getMethod();
        if (method == null) {
            lm.setMethod("no log entry");
        }

        lm.setData(msg);
        long startT = lm.getCt();
        if (startT > 0) {
            long endT = System.currentTimeMillis();
            lm.setT(DateFormatUtils.DateToString(new Date(startT),
                    DateStyle.YYYY_MM_DD_HH_MM_SS_SSS));
            lm.setCt((int) (endT - startT));
        }
        logger.error(JSON.toJSON(lm));
        SysLogModelContext.removeLm();
       
    }
    
    
    /**
     * 日志中只记录简单的错误信息, e.getMessage()的信息
     * 
     * @param msg
     * @param e
     */
    public void errorOnlyExceptionMsg(Object msg, Exception e) {
        LogModel<Object> lm = SysLogModelContext.getLm();
        String method = lm.getMethod();
        if (method == null) {
            lm.setMethod("no log entry");
        }

        lm.setData(msg);
        if(e != null) {
            lm.setEx(e.getMessage());
        }
        long startT = lm.getCt();
        if (startT > 0) {
            long endT = System.currentTimeMillis();
            lm.setT(DateFormatUtils.DateToString(new Date(startT),
                    DateStyle.YYYY_MM_DD_HH_MM_SS_SSS));
            lm.setCt((int) (endT - startT));
        }
        logger.error(JSON.toJSON(lm));
        SysLogModelContext.removeLm();
       
    }

    /**
     * 获取异常堆栈信息
     * @param cause
     * @return
     */
    private static String getExceptionTrace(Throwable cause) {
        if(cause == null) return "";
        String print = null;
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        PrintWriter wrt = new PrintWriter(bout);
        cause.printStackTrace(wrt);
        wrt.close();
        print = bout.toString();
        return print;
    }
    
    


}

 

 

另附(测试打印):

 

 

 

 

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值