PigCloud项目的使用解析四

1.mc-websocket-biz模块分析

/**
 * 
 */
package com.hst.mc.websocket.biz.advise;

import java.io.IOException;
import java.lang.reflect.Type;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice;
import com.google.common.base.Joiner;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;

/**
 * @author lijing1
 *
 */
@Slf4j
@RestControllerAdvice
public class RequestAopLogComponent implements RequestBodyAdvice {


    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType,
            Class<? extends HttpMessageConverter<?>> converterType) {
        //Auto-generated method stub
        return true;
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
            Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        //Auto-generated method stub
        return inputMessage;
    }

    @Override
    public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
            Class<? extends HttpMessageConverter<?>> converterType) {
        RequestMapping requestMapping = parameter.getMethodAnnotation(RequestMapping.class);
        log.info("请求地址====>{}", Joiner.on(',').join(requestMapping.value()));
        log.info("请求参数====>{}", JSONUtil.toJsonStr(body));
        return body;
    }

    @Override
    public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter,
            Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        RequestMapping requestMapping = parameter.getMethodAnnotation(RequestMapping.class);
        log.info("请求地址====>{}", Joiner.on(',').join(requestMapping.value()));
        return body;
    }
}

主要是实现父类方法,获取请求体的参数。
@ControllerAdvice 注解,可以用于定义@ExceptionHandler、@InitBinder、@ModelAttribute,并应用到所有@RequestMapping中

启动类注解分析:

package com.hst.mc.websocket.biz;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import com.hst.mc.common.swagger.annotation.EnableKnife4jSwagger2;
import com.hst.mc.websocket.biz.config.WebSocketConfig;
import com.pig4cloud.pig.common.feign.annotation.EnablePigFeignClients;
import com.pig4cloud.pig.common.job.annotation.EnablePigXxlJob;
import com.pig4cloud.pig.common.security.annotation.EnablePigResourceServer;

/**
* @author pig archetype
* <p>
* 项目启动类
*/
@EnablePigXxlJob           //自定义的注解
@EnableKnife4jSwagger2    //自定义的注解
@EnablePigFeignClients    //开启feign
@EnablePigResourceServer   //自定义的注解
@EnableDiscoveryClient     //向注册中心微服务客户端
@SpringBootApplication     //启动类注解
public class WebsocketApplication {

    public static void main(String[] args) {
        SpringApplication.run(WebsocketApplication.class, args);
    }

}

怎么写一个自定义注解(注解本质其实就是一个接口):

package com.pig4cloud.pig.common.job.annotation;

import com.pig4cloud.pig.common.job.XxlJobAutoConfiguration;
import org.springframework.context.annotation.Import;

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


/**
 * 激活xxl-job配置
 *
 * @author lishangbu
 * @date 2020/9/14
 */
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import({ XxlJobAutoConfiguration.class })
public @interface EnablePigXxlJob {

}

主要读取的XxlJobAutoConfiguration配置类,进行初始化。

package com.pig4cloud.pig.common.job;
import com.pig4cloud.pig.common.job.properties.XxlJobProperties;
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import com.xxl.job.core.util.NetUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import java.util.stream.Collectors;

/**
 * xxl-job自动装配
 *
 * @author lishangbu
 * @date 2020/9/14
 */
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@ComponentScan("com.pig4cloud.pig.common.job.properties")
@Slf4j
public class XxlJobAutoConfiguration {

    /**
     * 服务名称 包含 XXL_JOB_ADMIN 则说明是 Admin
     */
    private static final String XXL_JOB_ADMIN = "xxl-job-admin";

    /**
     * 配置xxl-job 执行器,提供自动发现 xxl-job-admin 能力
     *
     * @param xxlJobProperties xxl 配置
     * @param discoveryClient  注册发现客户端
     * @return XxlJobSpringExecutor
     */
    @Bean
    public XxlJobSpringExecutor xxlJobSpringExecutor(XxlJobProperties xxlJobProperties,
                                                     DiscoveryClient discoveryClient) {
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        xxlJobSpringExecutor.setAppname(xxlJobProperties.getExecutor().getAppname());
        xxlJobSpringExecutor.setAddress(xxlJobProperties.getExecutor().getAddress());
        xxlJobSpringExecutor.setIp(xxlJobProperties.getExecutor().getIp());
        xxlJobSpringExecutor.setPort(this.makePortAvaliable(xxlJobProperties.getExecutor().getPort()));
        xxlJobSpringExecutor.setAccessToken(xxlJobProperties.getExecutor().getAccessToken());
        xxlJobSpringExecutor.setLogPath(xxlJobProperties.getExecutor().getLogPath());
        xxlJobSpringExecutor.setLogRetentionDays(xxlJobProperties.getExecutor().getLogRetentionDays());

        // 如果配置为空则获取注册中心的服务列表 "http://pigx-xxl:9080/xxl-job-admin"
        if (! StringUtils.hasText(xxlJobProperties.getAdmin().getAddresses())) {
            String serverList = discoveryClient.getServices().stream().filter(s -> s.contains(XXL_JOB_ADMIN))
                .flatMap(s -> discoveryClient.getInstances(s).stream()).map(instance -> String
                    .format("http://%s:%s/%s", instance.getHost(), instance.getPort(), XXL_JOB_ADMIN))
                .collect(Collectors.joining(","));
            xxlJobSpringExecutor.setAdminAddresses(serverList);
            log.info("XxlJobAutoConfig adminAddresses:{}", serverList);
        } else {
            xxlJobSpringExecutor.setAdminAddresses(xxlJobProperties.getAdmin().getAddresses());
        }

        return xxlJobSpringExecutor;
    }

    /**
     * 因为xxl-job默认写死的9999和gateway的端口冲突了
     * 所以改写xxl-job, 如果大于0,则从port开始找合适的端口,如果小于等于0则从19999开始找端口
     * 考虑到并发执行下的效果,所以使用default为随机端口
     * 而又考虑到运维开放端口的可能新,随机范围进行限定(10000-10200)
     * 最终传给xxl执行的就是确定的端口
     *
     * @auther FredG_zhoupf
     */
    private int makePortAvaliable(Integer port) {
        Integer defaultPort = 10000 + (int) (Math.random() * 200);
        Integer targetPort = NetUtil.findAvailablePort(port > 0 ? port : defaultPort);
        log.info("this executor onlinePort is {}", targetPort);
        return targetPort;
    }
}

2.websocket的配置分析


package com.hst.mc.websocket.biz.config;

import java.net.URI;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.drafts.Draft_6455;
import org.java_websocket.handshake.ServerHandshake;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;

/**
 * @author lijing1
 *
 */
@Data
@Slf4j
@Configuration
public class WebSocketClientConfig {

    @DependsOn({"serverEndpointExporter", "webSocketServer"})  //先加载webSocketServer后再在加载webSocketClient
    @Lazy      //springIoc的延迟加载注解
    @Bean
    public WebSocketClient webSocketClient() {
        try {
            //socket服务端的地址配置
            WebSocketClient webSocketClient = new WebSocketClient(new URI("ws://192.168.3.79:7004/push/message/g999/c999"),new Draft_6455()) {
                @Override
                public void onOpen(ServerHandshake handshakedata) {
                    log.info("[websocket] 连接成功");
                }
 
                @Override
                public void onMessage(String message) {
                    log.info("[websocket] 收到消息={}",message);
 
                }
 
                @Override
                public void onClose(int code, String reason, boolean remote) {
                    log.info("[websocket] 退出连接");
                }
 
                @Override
                public void onError(Exception ex) {
                    log.info("[websocket] 连接错误={}",ex.getMessage());
                }
            };
            webSocketClient.connect();
            return webSocketClient;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}



package com.hst.mc.websocket.biz.service;

import java.io.IOException;
import java.util.Map.Entry;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import javax.annotation.Resource;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import org.springframework.stereotype.Component;
import com.alibaba.nacos.common.utils.Objects;
import com.hst.mc.websocket.api.enums.MsgTypeEnum;
import com.hst.mc.websocket.api.enums.WebsocketCode;
import com.hst.mc.websocket.api.exception.WebsocketException;
import com.hst.mc.websocket.biz.config.WebSocketConfig;
import com.hst.mc.websocket.biz.config.WebSocketServerConfig;
import com.xkcoding.http.util.MapUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;


/**
 * @author lijing1
 * 
 *  websocket服务端,多例的,一次websocket连接对应一个实例
 *  
 *  本实例可以扩展为简单的按群组聊天式的互动
 *  
 *  如下几个可以做成配置化,方便计费和用量控制 TODO
 *  1,可以控制到总的连接数; onlineCount; 10000
 *  2,后续可以控制到每个群组的最大连接数量;onlineCountPerGroup;(√²10000 ≈ 3000)
 *  3,可以控制总的群组数量; onlineGroup; 300
 */
@Component
//@ServerEndpoint注解是一个类层次的注解,它的功能主要是将目前的类定义成一个websocket服务器端,注解的值将被用于监听用户连接的终端访问URL地址,客户端可以通过这个URL来连接到WebSocket服务器端
@ServerEndpoint(value = "/push/message/{gid}/{cid}", configurator = WebSocketServerConfig.class)  
@Slf4j
public class WebSocketServer {
    
    @Resource
    private WebSocketConfig config;
    
    /** 用来记录当前在线连接数。设计成线程安全的。*/
    private static AtomicInteger onlineCount = new AtomicInteger(0);
    /** 用来记录当前在线群组。设计成线程安全的。*/
    private static AtomicInteger onlineGroup = new AtomicInteger(0);
    // TODO private static AtomicInteger onlineCountPerGroup = new AtomicInteger(0);
    
    /** 用于保存uri对应的连接服务,{uri:WebSocketServer},设计成线程安全的 */
    // private static ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketServer>> webSocketServerMAP = new ConcurrentHashMap<>();
    private static ConcurrentHashMap<String, ConcurrentHashMap<String, WebSocketServer>> webSocketServerMAP = new ConcurrentHashMap<>();
    
    public static ConcurrentHashMap<String, WebSocketServer> getServerByGid (String gid) {
        return webSocketServerMAP.get(gid);
    }
    
    private Session session;// 与某个客户端的连接会话,需要通过它来给客户端发送数据
    
    private String gid; //客户端消息发送者群组号
    private String cid; //客户端消息发送者
 
    /**
     * 连接建立成功时触发,绑定参数
     * @param session 可选的参数。session为与某个客户端的连接会话,需要通过它来给客户端发送数据
     * @param gid 群组id
     * @param cid 客户端id
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("gid") String gid, @PathParam("cid") String cid) {
        log.info("连接进入:群组{},客户端{}", gid, cid);
        // checkToken(session);
        
        this.session = session;
        this.gid = gid;
        this.cid = cid;
        ConcurrentHashMap<String, WebSocketServer> webSocketServerSet = webSocketServerMAP.get(gid);
        if (MapUtil.isEmpty(webSocketServerSet)) {
            webSocketServerSet = new ConcurrentHashMap<>();
        }
        webSocketServerSet.put(cid, Objects.isNull(webSocketServerSet.get(cid)) ? this : webSocketServerSet.get(cid));
        webSocketServerMAP.put(gid, webSocketServerSet);
        //        if(webSocketServer != null){ //同样业务的连接已经在线,则把原来的挤下线。
        //           webSocketServer.session.getBasicRemote().sendText(uri + "重复连接被挤下线了");
        //           webSocketServer.session.close();//关闭连接,触发关闭连接方法onClose()
        //        }
        //        webSocketServerMAP.put(uri, this);//保存uri对应的连接服务
        
        addOnlineCount(); // 在线数加1
        if (webSocketServerMAP.containsKey(gid)) {
            addOnlineGroup(); // 在线群组数加1
        }
        
    }
 
    private void checkToken(Session session2) {
        String token = (String) session.getUserProperties().get("token");
        log.info("token is : {}", token);
        if (!StrUtil.equals(token, config.getToken())) {
            throw new WebsocketException(WebsocketCode.EMPTY_CLIENT);
        }
    }

    /**
     * 连接关闭时触发,注意不能向客户端发送消息了
     */
    @OnClose
    public void onClose() {
        webSocketServerMAP.get(gid).remove(cid);
        if(webSocketServerMAP.get(gid).size() == 0) {
            webSocketServerMAP.remove(gid);
        }
        reduceOnlineCount(); // 在线数减1
        if (!webSocketServerMAP.containsKey(gid)) {
            reduceOnlineGroup(); // 在线群组数减1
        }
    }

    /**
     * 收到客户端消息后触发
     * @param message 客户端发送过来的消息
     */
    @OnMessage
    public void onMessage(String message) {
        log.info("收到消息:{}", message);
        try {
            JSONObject dto = JSONUtil.parseObj(message);
            Integer msgTypeInt = dto.getInt("msgType");
            String cid = dto.getStr("cid");
            String gid = dto.getStr("gid");
            String data = dto.getStr("data");
            switch (MsgTypeEnum.getByValue(msgTypeInt)) {
                case POINT :
                    sendInfo(cid, gid, data);
                    break;
                case GROUP :
                    sendInfo(gid, data);
                    break;
                case ANNOUNCEMENT :
                    sendInfo(data);
                    break;
                default:
                    break;
            }
        } catch (Exception ex) {
            log.error("收到消息格式不正确", ex.getMessage());
        }
    }
 
    /**
     * 通信发生错误时触发
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        try {
            log.info("群组{},客户端{}:通信发生错误,连接关闭", gid, cid);
            // TODO 是否要删除这一组连接 webSocketServerMAP.remove(gid);
            webSocketServerMAP.get(gid).remove(cid);
            if(webSocketServerMAP.get(gid).size() == 0) {
                webSocketServerMAP.remove(gid);
            }
            reduceOnlineCount(); // 在线数减1
            if (!webSocketServerMAP.containsKey(gid)) {
                reduceOnlineGroup(); // 在线群组数减1
            }
        }catch (Exception ex){
            log.error(ex.getMessage());
        }
    }
    
    /**
     * 实现服务器主动推送
     * @throws IOException 发送消息一次
     */
    public void sendMessage(String message) throws IOException {
        this.session.getBasicRemote().sendText(message);
        // this.session.getAsyncRemote().sendText(message);
    }
 
    /**
     * 获取在线连接数
     * @return
     */
   public static int getOnlineCount() {
        return onlineCount.get();
    }
   
   /**
    * 获取在线连接数
    * @return
    */
   public static int getOnlineGroup() {
       return onlineGroup.get();
   }

    /**
     * 原子性操作,在线连接数加一
     */
    public static void addOnlineCount() {
        onlineCount.getAndIncrement();
    }
    
    /**
     * 原子性操作,在线群组数加一
     */
    public static void addOnlineGroup() {
        onlineGroup.getAndIncrement();
    }

    /**
     * 原子性操作,在线连接数减一
     */
    public static void reduceOnlineCount() {
        onlineCount.getAndDecrement();
    }
    
    /**
     * 原子性操作,在线连接数减一
     */
    public static void reduceOnlineGroup() {
        onlineGroup.getAndDecrement();
    }
 
    /**
     * 发送自定义消息
     * */
    public static void sendInfo(String cid, String gid, String message) {
        ConcurrentHashMap<String, WebSocketServer> wsMap = webSocketServerMAP.get(gid);
        if (MapUtil.isEmpty(wsMap)) {
            log.warn("群组{}不存在", gid);
            return;
        } 
        if (Objects.isNull(wsMap.get("cid"))) {
            log.warn("群组{}存在,但是客户端{}不存在", gid, cid);
            return;
        } 
        try {
            log.info("推送消息到群组:{},客户端:{},推送内容:{}", gid, cid, message);
            wsMap.get("cid").sendMessage(message);
        } catch (Exception ex) {
            log.error("推送消息到窗口失败:{}", ex.getMessage());
        }
    }
    
    /**
     * 发送群发消息
     * */
    public static void sendInfo(String gid, String message) {
        log.info("开始推送消息到群组:{},推送内容:{}", gid, message);
        ConcurrentHashMap<String, WebSocketServer> wsMap = webSocketServerMAP.get(gid);
        if (MapUtil.isEmpty(wsMap)) {
            log.warn("群组{}不存在", gid);
            return;
        } 
        for (Entry<String, WebSocketServer> set : webSocketServerMAP.get(gid).entrySet()) {
            try {
                log.info("推送消息到群组:{},推送内容:{}", gid, message);
                set.getValue().sendMessage(message);
            } catch (Exception ex) {
                log.error("推送消息到窗口失败:{}", ex.getMessage());
                continue;
            }
        }
    }
    
    /**
     * 发送公告
     * */
    public static void sendInfo(String message) {
        log.info("推送消息给所有端,推送内容:{}", message);
        if (MapUtil.isEmpty(webSocketServerMAP)) {
            log.error("不存在连接");
        }
        for (Entry<String, ConcurrentHashMap<String, WebSocketServer>> mapEntry : webSocketServerMAP.entrySet()) {
            log.info("开始推送消息到群组:{}", mapEntry.getKey());
            for (Entry<String, WebSocketServer> setEntry : mapEntry.getValue().entrySet()) {
                try {
                    log.info("推送消息到窗口:{}", setEntry.getKey());
                    setEntry.getValue().sendMessage(message);
                } catch (Exception ex) {
                    log.error("推送消息到窗口失败:{}", ex.getMessage());
                    continue;
                }
            }
            log.info("结束开始推送消息到群组:{}", mapEntry.getKey());
        }
    }
 
}

/**
 * 
 */
package com.hst.mc.websocket.biz.config;



import java.util.List;
import java.util.Map;
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
import cn.hutool.core.collection.CollectionUtil;
import lombok.extern.slf4j.Slf4j;
 
/**
 * @author lijing1
 *
 */
@Slf4j
@Configuration
public class WebSocketServerConfig extends ServerEndpointConfig.Configurator{
 
    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }
    
    @Override
    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
        Map<String, Object> userProperties = sec.getUserProperties();
        Map<String, List<String>> headers = request.getHeaders();
        List<String> remoteIp = headers.get("token");
        if(CollectionUtil.isNotEmpty(remoteIp)){
            log.info("ws  token:" + remoteIp.get(0));
            userProperties.put("token", remoteIp.get(0));
        }
    }
}

这个被执行主要做的是将请求里面的token值存储到sec里面。


package com.hst.mc.websocket.biz.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
/**
 * @author lijing1
 *
 */
import lombok.Data;

@Data
/**作用
SpringCloud 使用 @RefreshScope注解,实现配置文件的动态加载。

使用方法
修改配置文件后,不重启应用。
在需要读取配置文件的地方添加 @RefreshScope注解
发送POST请求:http://localhost:port/actuator/refresh。
然后在重新发送controller层的请求,发现配置文件的更新已经生效了。
*/
@RefreshScope
@Configuration
@ConfigurationProperties(prefix = "websocket")
public class WebSocketConfig {

    private Integer maxOnlineCount = 100;

    private Integer maxOnlineGroup = 200;
    
    private String token = "hst123edu";

}

这是一个配置常量类。

package com.hst.mc.websocket.biz.controller;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.hst.mc.websocket.api.vo.WebsocketInfoVO;
import com.hst.mc.websocket.biz.config.WebSocketConfig;
import com.pig4cloud.pig.common.core.config.LocalConfiguration;
import com.pig4cloud.pig.common.core.util.R;
import io.swagger.annotations.Api;

/**
 * @author lijing1
 *
 */
@RestController
@RequestMapping(value = "/webSocketInfo")
@Api(value = "webSocketMessage", tags = "websocke信息")
@ApiSupport(order = 1)
public class WebSocketInfoController {
    
    
    @Resource
    private WebSocketConfig config;
    
    @Resource
    private LocalConfiguration localConfiguration;
    
    @GetMapping("/getWebSocketInfo")
    public R<WebsocketInfoVO> getWebSocketInfo() {
        WebsocketInfoVO vo = new WebsocketInfoVO();
        vo.setToken(config.getToken());
        //不用传request也可以通过以下方法获取request
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes()).getRequest();
        vo.setProtol("ws");
        vo.setIp(request.getRemoteHost());
        vo.setPort(request.getRemotePort());
        request.getServerPort();
        int port = request.getLocalPort();
        vo.setUrl("ws://" + localConfiguration.getIp() + ":" + port + "/push/message/{gid}/{cid}");
        return R.ok(vo);
    }
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bst@微胖子

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值