SpringBoot+SpringSecurity+SpringSession

本文介绍了在web项目中,如何从原来的会话保持过渡到使用SpringSession解决跨服务器会话共享的问题。通过添加依赖、配置Redis、重写SpringSecurity的session注册器以及调整WebSecurityConfigurerAdapter设置,最终通过Nginx进行负载均衡测试,确保登录状态在多台服务器间正常传递。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在一次的web项目开发中,初期用了公司的负载均衡,后台2台服务器,用了会话保持,所以在用户登录后没有问题。后来更换了域名和主体,没法实现会话保持,改成了springsession,记录下来操作。

一、加入依赖

<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.2.0.RELEASE</version>
</dependency>

二、配置redis


import java.net.UnknownHostException;
import java.util.HashSet;
import java.util.Set;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisNode;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.session.data.redis.config.ConfigureRedisAction;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

import redis.clients.jedis.JedisPoolConfig;

/**
 * redis集群JEDIS配置
 * redis在发送的时候,也采用@Retryable进行重试,因为公司有防火墙
 */
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800) //启用springsession
public class RedisConfig {

	@Value("${redis.address:ip:port,ip:port,ip:port}")
	private String redisAddress;

	@Value("${redis.timeout:5000}")
	private int timeout;

	@Value("${common.redis.password:pwd}")
	private String password;

	@Value("${redis.maxRedirects:5}")
	private int maxRedirects;

	@Value("${redis.minIdle:5}")
	private int minIdle;

	@Value("${redis.maxIdle:50}")
	private int maxIdle;

	@Value("${redis.maxTotal:200}")
	private int maxTotal;

	@Value("${redis.maxWaitMillis:100000}")
	private int maxWaitMillis;

	@Value("${redis.ssl:false}")
	private boolean ssl = false;

	@Bean(value = "redisTemplate")
	public RedisTemplate<Object, Object> redisTemplate(
			@Qualifier("jedisConnectionFactory") RedisConnectionFactory redisConnectionFactory) {
		RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<Object, Object>();
		redisTemplate.setConnectionFactory(redisConnectionFactory);
		return redisTemplate;
	}

	@Bean(value = "stringRedisTemplate")
	public StringRedisTemplate stringRedisTemplate(
			@Qualifier("jedisConnectionFactory") RedisConnectionFactory redisConnectionFactory)
			throws UnknownHostException {
		StringRedisTemplate template = new StringRedisTemplate();
		template.setConnectionFactory(redisConnectionFactory);
		return template;
	}

	@Bean(value = "jedisConnectionFactory")
	public JedisConnectionFactory jedisConnectionFactory() {
		RedisClusterConfiguration redisClusterConfiguration = new RedisClusterConfiguration();
		redisClusterConfiguration.setClusterNodes(getRedisTemplateAddress(redisAddress));
		redisClusterConfiguration.setMaxRedirects(maxRedirects);

		JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory(redisClusterConfiguration,
				jedisPoolConfig());
		jedisConnectionFactory.setTimeout(timeout);
		// 设置密码
		jedisConnectionFactory.setPassword(password);
		if (ssl) {
			jedisConnectionFactory.setUseSsl(ssl);
		}
		return jedisConnectionFactory;
	}

	private JedisPoolConfig jedisPoolConfig() {
		JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
		jedisPoolConfig.setMaxTotal(maxTotal);
		jedisPoolConfig.setMinIdle(minIdle);
		jedisPoolConfig.setMaxIdle(maxIdle);
		jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);
		return jedisPoolConfig;
	}

	private Set<RedisNode> getRedisTemplateAddress(String s) {
		Set<RedisNode> nodes = new HashSet<>();
		String regex = "(?:\\s|,)+";
		for (String hoststuff : s.split(regex)) {
			if ("".equals(hoststuff)) {
				continue;
			}

			int finalColon = hoststuff.lastIndexOf(':');
			if (finalColon < 1) {
				throw new IllegalArgumentException("Invalid server ``" + hoststuff + "'' in list:  " + s);
			}
			String hostPart = hoststuff.substring(0, finalColon);
			String portNum = hoststuff.substring(finalColon + 1);
			nodes.add(new RedisNode(hostPart, Integer.parseInt(portNum)));
		}
		return nodes;
	}
    
    //阿里云启用httpsession需要加上下面配置
	@Bean
	public static ConfigureRedisAction configureRedisAction() {
		return ConfigureRedisAction.NO_OP;
	}

}

三、重写SpringSecurity的session注册器


import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.data.redis.core.BoundHashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.session.SessionDestroyedEvent;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;

@Component
public class MySessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> {

	private static final String SESSIONIDS = "sessionIds";

	private static final String PRINCIPALS = "principals";

	@Autowired
	private RedisTemplate redisTemplate;

	private static final Logger logger = LoggerFactory.getLogger(MySessionRegistryImpl.class);

	// private final ConcurrentMap<Object, Set<String>> principals = new
	// ConcurrentHashMap();
	// private final Map<String, SessionInformation> sessionIds = new
	// ConcurrentHashMap();

	public MySessionRegistryImpl() {
	}

	@Override
	public List<Object> getAllPrincipals() {
		return new ArrayList(this.getPrincipalsKeySet());
	}

	@Override
	public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
		Set<String> sessionsUsedByPrincipal = this.getPrincipals(((UserDetails) principal).getUsername());
		if (sessionsUsedByPrincipal == null) {
			return Collections.emptyList();
		} else {
			List<SessionInformation> list = new ArrayList(sessionsUsedByPrincipal.size());
			Iterator var5 = sessionsUsedByPrincipal.iterator();

			while (true) {
				SessionInformation sessionInformation;
				do {
					do {
						if (!var5.hasNext()) {
							return list;
						}

						String sessionId = (String) var5.next();
						sessionInformation = this.getSessionInformation(sessionId);
					} while (sessionInformation == null);
				} while (!includeExpiredSessions && sessionInformation.isExpired());

				list.add(sessionInformation);
			}
		}
	}

	@Override
	public SessionInformation getSessionInformation(String sessionId) {
		Assert.hasText(sessionId, "SessionId required as per interface contract");
		return this.getSessionInfo(sessionId);
	}

	@Override
	public void onApplicationEvent(SessionDestroyedEvent event) {
		String sessionId = event.getId();
		this.removeSessionInformation(sessionId);
	}

	@Override
	public void refreshLastRequest(String sessionId) {
		Assert.hasText(sessionId, "SessionId required as per interface contract");
		SessionInformation info = this.getSessionInformation(sessionId);
		if (info != null) {
			info.refreshLastRequest();
		}

	}

	@Override
	public void registerNewSession(String sessionId, Object principal) {
		Assert.hasText(sessionId, "SessionId required as per interface contract");
		Assert.notNull(principal, "Principal required as per interface contract");
		if (this.logger.isDebugEnabled()) {
			this.logger.debug("Registering session " + sessionId + ", for principal " + principal);
		}

		if (this.getSessionInformation(sessionId) != null) {
			this.removeSessionInformation(sessionId);
		}

		this.addSessionInfo(sessionId, new SessionInformation(principal, sessionId, new Date()));

		// this.sessionIds.put(sessionId, new SessionInformation(principal,
		// sessionId, new Date()));
		Set<String> sessionsUsedByPrincipal = this.getPrincipals(principal.toString());
		if (sessionsUsedByPrincipal == null) {
			sessionsUsedByPrincipal = new CopyOnWriteArraySet();
			Set<String> prevSessionsUsedByPrincipal = this.putIfAbsentPrincipals(principal.toString(),
					sessionsUsedByPrincipal);
			if (prevSessionsUsedByPrincipal != null) {
				sessionsUsedByPrincipal = prevSessionsUsedByPrincipal;
			}
		}

		((Set) sessionsUsedByPrincipal).add(sessionId);
		this.putPrincipals(principal.toString(), sessionsUsedByPrincipal);
		if (this.logger.isTraceEnabled()) {
			this.logger.trace("Sessions used by '" + principal + "' : " + sessionsUsedByPrincipal);
		}

	}

	@Override
	public void removeSessionInformation(String sessionId) {
		Assert.hasText(sessionId, "SessionId required as per interface contract");
		SessionInformation info = this.getSessionInformation(sessionId);
		if (info != null) {
			if (this.logger.isTraceEnabled()) {
				this.logger.debug("Removing session " + sessionId + " from set of registered sessions");
			}

			this.removeSessionInfo(sessionId);
			Set<String> sessionsUsedByPrincipal = this.getPrincipals(info.getPrincipal().toString());
			if (sessionsUsedByPrincipal != null) {
				if (this.logger.isDebugEnabled()) {
					this.logger.debug("Removing session " + sessionId + " from principal's set of registered sessions");
				}

				sessionsUsedByPrincipal.remove(sessionId);
				if (sessionsUsedByPrincipal.isEmpty()) {
					if (this.logger.isDebugEnabled()) {
						this.logger.debug("Removing principal " + info.getPrincipal() + " from registry");
					}

					this.removePrincipal(((UserDetails) info.getPrincipal()).getUsername());
				}

				if (this.logger.isTraceEnabled()) {
					this.logger.trace("Sessions used by '" + info.getPrincipal() + "' : " + sessionsUsedByPrincipal);
				}

			}
		}
	}

	public void addSessionInfo(final String sessionId, final SessionInformation sessionInformation) {
		BoundHashOperations<String, String, SessionInformation> hashOperations = redisTemplate.boundHashOps(SESSIONIDS);
		hashOperations.put(sessionId, sessionInformation);
	}

	public SessionInformation getSessionInfo(final String sessionId) {
		BoundHashOperations<String, String, SessionInformation> hashOperations = redisTemplate.boundHashOps(SESSIONIDS);
		return hashOperations.get(sessionId);
	}

	public void removeSessionInfo(final String sessionId) {
		BoundHashOperations<String, String, SessionInformation> hashOperations = redisTemplate.boundHashOps(SESSIONIDS);
		hashOperations.delete(sessionId);
	}

	public Set<String> putIfAbsentPrincipals(final String key, final Set<String> set) {
		BoundHashOperations<String, String, Set<String>> hashOperations = redisTemplate.boundHashOps(PRINCIPALS);
		hashOperations.putIfAbsent(key, set);
		return hashOperations.get(key);
	}

	public void putPrincipals(final String key, final Set<String> set) {
		BoundHashOperations<String, String, Set<String>> hashOperations = redisTemplate.boundHashOps(PRINCIPALS);
		hashOperations.put(key, set);
	}

	public Set<String> getPrincipals(final String key) {
		BoundHashOperations<String, String, Set<String>> hashOperations = redisTemplate.boundHashOps(PRINCIPALS);
		return hashOperations.get(key);
	}

	public Set<String> getPrincipalsKeySet() {
		BoundHashOperations<String, String, Set<String>> hashOperations = redisTemplate.boundHashOps(PRINCIPALS);
		return hashOperations.keys();
	}

	public void removePrincipal(final String key) {
		BoundHashOperations<String, String, Set<String>> hashOperations = redisTemplate.boundHashOps(PRINCIPALS);
		hashOperations.delete(key);
	}

}

四、WebSecurityConfigurerAdapter配置


import java.io.IOException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.PortMapper;
import org.springframework.security.web.PortMapperImpl;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.security.web.util.RedirectUrlBuilder;
import org.springframework.security.web.util.UrlUtils;

import com.xxx.security.MchCustomUserDetailService;
import com.xxx.security.MyPasswordEncoder;
import com.xxx.security.PamchFilterSecurityInterceptor;


@Configuration
@EnableGlobalMethodSecurity
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	@Autowired
	private MchCustomUserDetailService mchUserDetailService;

	@Autowired
	private PamchFilterSecurityInterceptor loginAuthInterceptor;

	@Override
	public void configure(HttpSecurity http) throws Exception {
		http.exceptionHandling()
				.authenticationEntryPoint(new UnauthorizedEntryPoint())
				.and()
				.csrf()
				.disable()
				// 登录验证
				.authorizeRequests()
				.antMatchers("/login/**", "/login**", "/visitWeb", "/smct/idiomJcaptchaService*",
						"/smct/charJcaptchaService*", "/smct/arithJcaptchaService*", "/operatorinfo/*", "/security/*",
						"/static/*", "/merchantRequest", "/merchantRequest/*", "/onlinePay/obcAsyncReturn",
						"/ecmsContract/notify", "/contract**", "/ecmsCustomerCon/*", "/ecmsContractCon/*",
						"/ecmsTemplateCon/*", "/monitorWeb", "/api/**", "/front/**").permitAll()
				// 任何请求,登录后可以访问
				.anyRequest().authenticated()
				// 登录
				.and().formLogin().loginPage("/login").loginProcessingUrl("/security/j_spring_security_check")
				.successHandler(loginSuccessHandler()).failureHandler(loginFailureHandler()).permitAll()
				// 登出
				.and().logout().logoutSuccessHandler(logoutSuccessHandler()).permitAll()// 跨域
				//session管理 .and().sessionManagement().maximumSessions(1).sessionRegistry(mySessionRegistry());
		// 过滤器
		// 过验证码
		http.addFilterBefore(loginConfirmCodeFilter(), UsernamePasswordAuthenticationFilter.class);
		http.addFilterBefore(loginAuthInterceptor, FilterSecurityInterceptor.class);
	}

	@Override
	public void configure(WebSecurity web) throws Exception {
		web.ignoring().antMatchers("/resources/**", "/statics/**", "/common/*");
	}

	@Override
	public void configure(AuthenticationManagerBuilder auth) throws Exception {
		// 登录用户的注册认证密码加密
		auth.authenticationProvider(daoAuthenticationProvider());
		// auth.userDetailsService(mchUserDetailService).passwordEncoder(passwordEncoder());
	}

	@Bean("authenticationManager")
	@Override
	public AuthenticationManager authenticationManagerBean() throws Exception {
		return super.authenticationManagerBean();
	}

	/**
	 * 判断是否需要加判断是否需要加入虚拟路径
	 */
	@Value("${security.contextRelative:false}")
	private boolean contextRelative;

	/**
	 * 用来开启是否启动强制https跳转
	 */
	@Value("${security.forceHttps:false}")
	private boolean forceHttps;

	/***
	 * 登录成功
	 * 
	 * @return
	 */
	private AuthenticationSuccessHandler loginSuccessHandler() {
		SavedRequestAwareAuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
		successHandler.setDefaultTargetUrl("/index");
		successHandler.setAlwaysUseDefaultTargetUrl(true);
		successHandler.setRedirectStrategy(new SecurityRedirectStrategy(contextRelative, forceHttps));
		return successHandler;
	}

	/***
	 * 登录失败
	 */
	private AuthenticationFailureHandler loginFailureHandler() {
		SimpleUrlAuthenticationFailureHandler failureHandle = new SimpleUrlAuthenticationFailureHandler();
		failureHandle.setDefaultFailureUrl("/login");
		failureHandle.setRedirectStrategy(new SecurityRedirectStrategy(contextRelative, forceHttps));
		return failureHandle;
	}

	/***
	 * 登出成功
	 * 
	 * @return
	 */
	private LogoutSuccessHandler logoutSuccessHandler() {
		SimpleUrlLogoutSuccessHandler simpleUrlLogoutSuccessHandler = new SimpleUrlLogoutSuccessHandler();
		simpleUrlLogoutSuccessHandler.setDefaultTargetUrl("/login");
		simpleUrlLogoutSuccessHandler.setRedirectStrategy(new SecurityRedirectStrategy(contextRelative, forceHttps));
		return simpleUrlLogoutSuccessHandler;
	}

	/***
	 * 登录确认失败
	 * 
	 * @return
	 */
	private AuthenticationFailureHandler loginConfirmFailureHandler() {
		SimpleUrlAuthenticationFailureHandler loginConfirmFailureHandler = new SimpleUrlAuthenticationFailureHandler();
		loginConfirmFailureHandler.setDefaultFailureUrl("/login");
		loginConfirmFailureHandler.setRedirectStrategy(new SecurityRedirectStrategy(contextRelative, forceHttps));
		return loginConfirmFailureHandler;
	}

	@Bean
	public LoginConfirmCodeFilter loginConfirmCodeFilter() {
		LoginConfirmCodeFilter filter = new LoginConfirmCodeFilter();
		filter.setFilterProcessesUrl("/security/j_spring_security_check");
		filter.setPostOnly(true);
		filter.setAuthenticationFailureHandler(loginConfirmFailureHandler());
		return filter;
	}

	@Bean(name = "daoAuthenticationProvider")
	public AuthenticationProvider daoAuthenticationProvider() {
		DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
		daoAuthenticationProvider.setUserDetailsService(mchUserDetailService);
		daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
		daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
		return daoAuthenticationProvider;
	}

	@Bean
	public MyPasswordEncoder passwordEncoder() {
		return new MyPasswordEncoder();
	}

	@Bean
	public MySessionRegistryImpl mySessionRegistry() {
		return new MySessionRegistryImpl();
	}

	/**
	 * 用来处理security中https跳转
	 */
	class SecurityRedirectStrategy implements RedirectStrategy {

		private PortMapper portMapper = new PortMapperImpl();

		private boolean contextRelative;

		private boolean forceHttps;

		public SecurityRedirectStrategy(boolean contextRelative, boolean forceHttps) {
			this.contextRelative = contextRelative;
			this.forceHttps = forceHttps;
		}

		@Override
		public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url)
				throws IOException {
			String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
			redirectUrl = response.encodeRedirectURL(redirectUrl);

			if (forceHttps) {
				RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();
				urlBuilder.setScheme("https");
				urlBuilder.setServerName(request.getServerName());
				Integer httpsPort = portMapper.lookupHttpsPort(request.getServerPort());
				urlBuilder.setPort(httpsPort);
				urlBuilder.setContextPath(request.getContextPath());
				urlBuilder.setServletPath(url);
				urlBuilder.setPathInfo(request.getPathInfo());
				urlBuilder.setQuery(request.getQueryString());

				redirectUrl = urlBuilder.getUrl();
			}
			response.sendRedirect(redirectUrl);
		}

		private String calculateRedirectUrl(String contextPath, String url) {
			if (!UrlUtils.isAbsoluteUrl(url)) {
				if (contextRelative) {
					return url;
				} else {
					return contextPath + url;
				}
			}

			if (!contextRelative) {
				return url;
			}

			url = url.substring(url.lastIndexOf("://") + 3);
			url = url.substring(url.indexOf(contextPath) + contextPath.length());

			if (url.length() > 1 && url.charAt(0) == '/') {
				url = url.substring(1);
			}

			return url;
		}
	}
}

 

五、测试

1、下载ngnix windows版本,解压到本地

2、修改ngnix.conf文件如下:


#user  nobody;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;
    

#后台服务的ip和端口
    upstream tomcat{
        server ip1:port weight=1;
        server ip2:port weight=1;
    }

    server {
        listen       8081;
        server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

        location / {
            root   html;
            index  index.html index.htm;
            proxy_pass http://tomcat;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }

}
3、启动ngnix

docs进入到ngnix根目录

--启动命令

D:\Program\nginx-1.12.2>start nginx

--停止命令

D:\Program\nginx-1.12.2>nginx -s stop

启动后访问下面端口,跳转到ngnix主页,说明成功

http://localhost:8081

在访问项目路径

http://localhost:8081/projectname/login

登录成功并且能2台服务器都可以接受到请求,说明成功。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值