大华网络摄像头视频播放(基于vue+SpringBoot)

引言

近期接到一个需求,需要实现下面2点。

1.实时预览大华网络摄像机(IPC);2.网络硬盘录像机(NVR)的回放。

笔者通过大华官网支持(support)的页面,下载了WEB相关的SDK(WEB无插件开发包),却发现无法实现公网访问。退而求其次寻求替代方案,最后确定使用Java后端转发视频流的方式来实现需求。

本文参考了下面2位作者的文章,在此表示非常感谢。

大华SDK+JAVA+4g网络摄像头进行二次开发(Cljxy~)

vue+flv.js+SpringBoot+websocket实现视频监控与回放(香草味糖葫芦)

开发前准备

1.大华IPC摄像头;2.大华NetSDK_JAVA;3.IntelliJ IDEA。

技术分析

【实时预览】

根据大华《NetSDK_JAVA编程指导手册》的流程图

目标:在【回调fRealDataCallBackEx 解析码流】这个步骤,得到码流推送至前端解码即可。

此流程图中没有外网访问的功能,该功能需要另一份开发文档【NetSDK_JAVA 主动注册】来实现。

【主动注册】

主动注册需要启用监听服务器监听9500端口,然后在网络摄像头的设置中按图设置好服务器的ip地址、端口、子设备ID(需要保证系统唯一性)。

设备会隔间30秒左右向服务器注册自己的信息(【ip地址】,【端口号】,【设备ID】)。然后在设备的信息基础上增加【设备账户】与【设备密码】,即可得到登录句柄。

什么是句柄?
JAVA 开发人员很少接触、使用句柄。句柄是一个唯一的整数,作为对象的身份id,用于区分不同的对象和同类中的不同实例。程序可以通过句柄访问对象的部分信息。JAVA 中任何东西都可以视为对象。我们可以认为操纵的标识符实际是指向一个对象的“句柄"(Handle)。

【最终方案】

1.Java后端通过NetSDK得到IPC回调的FLV流;

2.后端与前端通过websocket进行数据的传输;

3.前端通过后端转发的FLV流,使用flv.js进行解析并播放。

代码实现

1.新建springboot项目

2.导入相关的依赖和资源

lib包与common包可以从大华的demo中直接复制过来。linnx64与win64按需要复制即可。要注意NetSDKLib.java文件在windows与liunx中有差异,请按实际需要选择。

3.SDK启动

按照大华示例,进行相应模块的编写,如LoginModule、AutoRegisterModule、ServiceCB、DisConnect、HaveReConnect的编写,可以复制相关示例或者自己按需编写。

public class LoginModule {

    public static NetSDKLib netSdk 	= NetSDKLib.NETSDK_INSTANCE;
    // 设备信息
    public static NetSDKLib.NET_DEVICEINFO_Ex m_stDeviceInfo = new NetSDKLib.NET_DEVICEINFO_Ex();
    // 登陆句柄
    public static NetSDKLib.LLong m_hLoginHandle = new NetSDKLib.LLong(0);
    private static boolean bInit    = false;
    private static boolean bLogopen = false;
    /**
     * 初始化
     */
    public static boolean init(NetSDKLib.fDisConnect disConnect, NetSDKLib.fHaveReConnect haveReConnect) {
        bInit = netSdk.CLIENT_Init(disConnect, null);
        if(!bInit) {
            System.out.println("Initialize SDK failed");
            return false;
        }

        //打开日志,可选
        NetSDKLib.LOG_SET_PRINT_INFO setLog = new NetSDKLib.LOG_SET_PRINT_INFO();
        File path = new File("./sdklog/");
        if (!path.exists()) {
            path.mkdir();
        }
        String logPath = path.getAbsoluteFile().getParent() + "\\sdklog\\" + ToolKits.getDate() + ".log";
        setLog.nPrintStrategy = 0;
        setLog.bSetFilePath = 1;
        System.arraycopy(logPath.getBytes(), 0, setLog.szLogFilePath, 0, logPath.getBytes().length);
        System.out.println(logPath);
        setLog.bSetPrintStrategy = 1;
        bLogopen = netSdk.CLIENT_LogOpen(setLog);
        if(!bLogopen ) {
            System.err.println("Failed to open NetSDK log");
        }

        // 设置断线重连回调接口,设置过断线重连成功回调函数后,当设备出现断线情况,SDK内部会自动进行重连操作
        // 此操作为可选操作,但建议用户进行设置
        netSdk.CLIENT_SetAutoReconnect(haveReConnect, null);

        //设置登录超时时间和尝试次数,可选
        int waitTime = 5000; //登录请求响应超时时间设置为5S
        int tryTimes = 1;    //登录时尝试建立链接1次
        netSdk.CLIENT_SetConnectTime(waitTime, tryTimes);

        // 设置更多网络参数,NET_PARAM的nWaittime,nConnectTryNum成员与CLIENT_SetConnectTime
        // 接口设置的登录设备超时时间和尝试次数意义相同,可选
        NetSDKLib.NET_PARAM netParam = new NetSDKLib.NET_PARAM();
        netParam.nConnectTime = 10000;      // 登录时尝试建立链接的超时时间
        netParam.nGetConnInfoTime = 3000;   // 设置子连接的超时时间
        netParam.nGetDevInfoTime = 3000;//获取设备信息超时时间,为0默认1000ms
        netSdk.CLIENT_SetNetworkParam(netParam);

        return true;
    }
    /**
     * 清除环境
     */
    public static void cleanup() {
        if(bLogopen) {
            netSdk.CLIENT_LogClose();
        }

        if(bInit) {
            netSdk.CLIENT_Cleanup();
        }
    }

    /**
     * 登录设备
     */
    public static boolean login(String m_strIp, int m_nPort, String m_strUser, String m_strPassword) {
        //IntByReference nError = new IntByReference(0);
        //入参
        NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY pstInParam=new NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY();
        pstInParam.nPort=m_nPort;
        pstInParam.szIP=m_strIp.getBytes();
        pstInParam.szPassword=m_strPassword.getBytes();
        pstInParam.szUserName=m_strUser.getBytes();
        //出参
        NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY pstOutParam=new NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY();
        pstOutParam.stuDeviceInfo=m_stDeviceInfo;
        
        m_hLoginHandle = netSdk.CLIENT_LoginWithHighLevelSecurity(pstInParam, pstOutParam);
        if(m_hLoginHandle.longValue() == 0) {
            System.err.printf("Login Device[%s] Port[%d]Failed. %s\n", m_strIp, m_nPort, ToolKits.getErrorCodePrint());
        } else {
            System.out.println("Login Success [ " + m_strIp + " ]");
        }

        return m_hLoginHandle.longValue() == 0? false:true;
    }

    /**
     * 登出设备
     */
    public static boolean logout() {
        if(m_hLoginHandle.longValue() == 0) {
            return false;
        }

        boolean bRet = netSdk.CLIENT_Logout(m_hLoginHandle);
        if(bRet) {
            m_hLoginHandle.setValue(0);
        }

        return bRet;
    }
}
public class AutoRegisterModule {

    public static final NetSDKLib DHNetSdkLib = NetSDKLib.NETSDK_INSTANCE;
    // 监听服务句柄
    public static NetSDKLib.LLong mServerHandler = new NetSDKLib.LLong(0);
    // 主动注册监听回调
    private static ServiceCB serviceCallback = new ServiceCB();
    // 设备断线回调
    private static DisConnect disConnectCallback = new DisConnect();
    // 设备重连通知回调
    private static HaveReConnect haveReConnect = new HaveReConnect();
    // 设备列表

    /**
     * 开启服务
     * @param address 本地IP地址
     * @param port 本地端口, 建议9500
     */
    public static boolean startServer(String address, int port) {
        //SDK初始化,并设置回调
        boolean flag = LoginModule.init(disConnectCallback, haveReConnect);
        if (flag){
            System.out.println("注册成功");
        }
        mServerHandler = DHNetSdkLib.CLIENT_ListenServer(address, port, 1000, serviceCallback, null);
        if (0 == mServerHandler.longValue()) {
            System.err.println("Failed to start server." + ToolKits.getErrorCodePrint());
        } else {
            System.out.printf("Start server, [Server address %s][Server port %d]\n", address, port);
        }
        return mServerHandler.longValue() != 0;
    }

    /**
     * 结束服务
     */
    public static boolean stopServer() {
        boolean flag = false;
        if(mServerHandler.longValue() != 0) {
            flag = DHNetSdkLib.CLIENT_StopListenServer(mServerHandler);
            mServerHandler.setValue(0);
            System.out.println("Stop server!");
        }
        return flag;
    }

    public static boolean getServerState() {
        return mServerHandler.longValue() != 0;
    }

    /**
     * 设备断线回调: 通过 CLIENT_Init 设置该回调函数,当设备出现断线时,SDK会调用该函数
     */
    private static class DisConnect implements NetSDKLib.fDisConnect {
        public void invoke(NetSDKLib.LLong m_hLoginHandle, String pchDVRIP, int nDVRPort, Pointer dwUser) {
            System.out.printf("Device[%s] Port[%d] DisConnect!\n", pchDVRIP, nDVRPort);
        }
    }

    /**
     * 设备重连回调: 通过 CLIENT_Init 设置该回调函数,当设备出现断线时,SDK会调用该函数
     */
    private static class HaveReConnect implements NetSDKLib.fHaveReConnect {
        public void invoke(NetSDKLib.LLong m_hLoginHandle, String pchDVRIP, int nDVRPort, Pointer dwUser) {
            System.out.printf("ReConnect Device[%s] Port[%d]\n", pchDVRIP, nDVRPort);
        }
    }

}
public class ServiceCB implements NetSDKLib.fServiceCallBack {
    @Override
    public int invoke(NetSDKLib.LLong lHandle, final String pIp, final int wPort,
                      int lCommand, Pointer pParam, int dwParamLen,
                      Pointer dwUserData) {

        // 将 pParam 转化为序列号
        byte[] buffer = new byte[dwParamLen];
        pParam.read(0, buffer, 0, dwParamLen);
        String deviceId = "";
        try {
            deviceId = new String(buffer, "GBK").trim();
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }

        System.out.printf("Register Device Info [Device address %s][port %s][DeviceID %s] \n", pIp, wPort, deviceId);
        switch(lCommand) {
            case NetSDKLib.EM_LISTEN_TYPE.NET_DVR_DISCONNECT: {  // 验证期间设备断线回调
                break;
            }
            case NetSDKLib.EM_LISTEN_TYPE.NET_DVR_SERIAL_RETURN: {// 设备注册携带序列号
                List<DevicesModule> deviceList = DeviceListUtil.getDeviceList();
                for (DevicesModule deviceModule : deviceList) {
                    if (deviceModule.getDeviceInfo().getDid().equals(deviceId)) {
                        //设置为虚拟pIp,wPort
                        deviceModule.getDeviceInfo().setDIp(pIp);
                        deviceModule.getDeviceInfo().setDPort(wPort);
                        Executors.newSingleThreadExecutor().submit(new Runnable() {
                            @Override
                            public void run() {
                                //设备登陆,标记状态
                                deviceModule.login();
                            }
                        });
                    }
                }
                break;
            }
            default:
                break;
        }
        return 0;
    }
}

这里主要是通过AutoRegisterModule.startServer(serverIP, serverPort)方法来启动自动注册监听,设备通过设置自动注册后,将自己的信息通过回调进ServiceCB中,然后根据设备信息登录来获取【设备登录句柄】。

4.websocket

websocket的示例很多,第一步是pom中引入

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

配置WebSocketConfig

@Configuration
public class WebSocketConfig {
    /**
     * 	注入ServerEndpointExporter,
     * 	这个bean会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

编写

@ServerEndpoint("/device/monitor/{device}/{channel}")
@Component
@Slf4j
public class WebSocketServer {
    public static VideoMonitorService service;
    /**
     * 静态变量,用来记录当前在线连接数。应该把它设计成线程安全
     */
    private final AtomicInteger onlineCount = new AtomicInteger(0);
    /**
     * 存放每个客户端对应的WebSocket对象,根据设备realPlayHandler建立session
     */
    public static ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
    /**
     * 保存覆盖播放标识
     */
    public static CopyOnWriteArrayList<String> sessionList = new CopyOnWriteArrayList<>();

    /**
     * 有websocket client连接
     * @param device 设备ID
     * @param channel 预览句柄
     * @param session
     */
    @OnOpen
    public void OnOpen(@PathParam("device") String device, @PathParam("channel") String channel, Session session)  throws InterruptedException {
        log.info("连接进入");
        //设备ID+预览句柄组成唯一性标识
        String uuid = device + channel;
        if (sessions.containsKey(uuid)) {
            sessions.put(uuid, session);
        } else {
            sessions.put(uuid, session);
            addOnlineCount();
        }
        log.info("websocket connect.session: " + session);
    }

    /**
     * 连接关闭调用的方法
     * @param device 设备ID
     * @param channel 预览句柄
     * @param session websocket连接对象
     */
    @OnClose
    public void onClose(@PathParam("device") String device, @PathParam("channel") String channel, Session session){
        String uuid = device + channel;
        if (sessions.containsKey(uuid)) {
            sessions.remove(uuid);
            subOnlineCount();
            try{
                if(service != null){
                    long handle = AutoRegisterEventModule.findRealPlayInfoByDeviceIdAndChannelNum(uuid);
                    RealPlayInfo realPlayInfo = AutoRegisterEventModule.findRealPlayInfo(handle);
                    if(realPlayInfo != null){
                        System.out.println(uuid + ":websocket 断开连接!");
                        boolean b = false;
                        if("1".equals(realPlayInfo.getType())){
                            b = service.stopPlay(new NetSDKLib.LLong(handle),realPlayInfo.getDeviceId());
                        }else if("2".equals(realPlayInfo.getType())){
                            b = service.stopReport(new NetSDKLib.LLong(handle),realPlayInfo.getDeviceId());
                        }
                        if(b){
                            AutoRegisterEventModule.removeRealPlayInfo(handle);
                        }
                    }else{

                    }
                }
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }

    /**
     * 发生错误
     * @param throwable e
     */
    @OnError
    public void onError(Throwable throwable) {
        throwable.printStackTrace();
    }

    /**
     * 收到客户端发来消息
     * @param message 消息对象
     */
    @OnMessage
    public void onMessage(ByteBuffer message) {
        log.info("服务端收到客户端发来的消息: {}", message);
    }

    /**
     * 收到客户端发来消息
     * @param message 字符串类型消息
     */
    @OnMessage
    public void onMessage(String message) {
        log.info("服务端收到客户端发来的消息: {}", message);
    }

    /**
     * 发送消息
     * @param message 字符串类型的消息
     */
    public void sendAll(String message) {
        for (Map.Entry<String, Session> session : sessions.entrySet()) {
            session.getValue().getAsyncRemote().sendText(message);
        }
    }

    /**
     * 发送binary消息
     * @param buffer
     */
    public void sendMessage(ByteBuffer buffer) {
        for (Map.Entry<String, Session> session : sessions.entrySet()) {
            session.getValue().getAsyncRemote().sendBinary(buffer);
        }
    }

    /**
     * 转发数据流
     * @param realPlayHandler 预览句柄
     * @param buffer          码流数据
     */
    public void forwardDataFlow(long realPlayHandler, ByteBuffer buffer) {
        //登录句柄无效
        if (realPlayHandler == 0) {
            log.error("loginHandler is invalid.please check.", this);
            return;
        }
        RealPlayInfo realPlayInfo = AutoRegisterEventModule.findRealPlayInfo(realPlayHandler);
        if(realPlayInfo == null){
            //连接已断开
        }
        String key = realPlayInfo.getDeviceId()+realPlayInfo.getChannel();
        Session session = sessions.get(key);
        if (session != null) {
            synchronized (session) {
                try {
                    session.getBasicRemote().sendBinary(buffer);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        } else {
            log.error("session is null.please check.", this);
        }
    }

    /**
     * 主动关闭websocket连接
     * @param realPlayHandler 预览句柄
     */
    public void closeSession(long realPlayHandler) {
        try {
            Session session = sessions.get(realPlayHandler);
            if (session != null) {
                session.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 获取当前连接数
     * @return
     */
    public int getOnlineCount() {
        return onlineCount.get();
    }

    /**
     * 增加当前连接数
     * @return
     */
    public int addOnlineCount() {
        return onlineCount.getAndIncrement();
    }

    /**
     * 减少当前连接数
     * @return
     */
    public int subOnlineCount() {
        return onlineCount.getAndDecrement();
    }

    public static boolean crrentSession(String key){
        if(sessions.containsKey(key)){
            return true;
        }
        return false;
    }
}

主要方法是forwardDataFlow方法,在摄像头的流数据回调至NetSDKLib.fRealDataCallBackEx时,用此方法,可以转给对应的前端用于视频流的展示。

5.预览

将预览服务封装进一个service服务中,方便调用

public class VideoMonitorService {

    @Autowired
    private WebSocketRealDataCallback webSocketRealDataCallback;

    /**
     *  视频预览
     */
    public long startRealPlay(String deviceId,int channelNum,int rType){
        DevicesModule devicesModule = DeviceListUtil.getDeviceModuleByDeviceId(deviceId);
        if(devicesModule != null)
            return devicesModule.startRealPlay(channelNum,webSocketRealDataCallback,deviceId,rType);
        return 0;
    }

    public boolean stopPlay(NetSDKLib.LLong playHandle, String deviceId){
        DevicesModule devicesModule = DeviceListUtil.getDeviceModuleByDeviceId(deviceId);
        if(devicesModule != null)
            return devicesModule.stopPlay(playHandle);
        return false;
    }
}

通过调用VideoMonitorService中的startRealPlay方法,通过设备ID获取到已在自动注册成功后存储的设备登录句柄,然后再调用封装的startRealPlay方法来实现预览的回调

public long startRealPlay(NetSDKLib.LLong m_hLoginHandle, int channelNum, WebSocketRealDataCallback webSocketRealDataCallback, String deviceId, int rType) {
        //入参对象
        int emDataType = NetSDKLib.EM_REAL_DATA_TYPE.EM_REAL_DATA_TYPE_FLV_STREAM;
        NetSDKLib.NET_IN_REALPLAY_BY_DATA_TYPE inParam = new NetSDKLib.NET_IN_REALPLAY_BY_DATA_TYPE();
        inParam.emDataType = emDataType;
        inParam.nChannelID = channelNum;
        inParam.emAudioType = EM_AUDIO_DATA_TYPE.EM_AUDIO_DATA_TYPE_AAC.ordinal();
        inParam.rType = rType;
        inParam.cbRealData = webSocketRealDataCallback;
        inParam.dwUser = null;

        //返回对象
        NetSDKLib.NET_OUT_REALPLAY_BY_DATA_TYPE stOut = new NetSDKLib.NET_OUT_REALPLAY_BY_DATA_TYPE();
        //获取预览句柄
        NetSDKLib.LLong lRealHandle = DHNetSdkLib.CLIENT_RealPlayByDataType(m_hLoginHandle, inParam, stOut, 5000);
        //开启实时监控
        if(lRealHandle.longValue() != 0) {
            RealPlayInfo info = new RealPlayInfo(m_hLoginHandle.longValue(), emDataType, channelNum, NetSDKLib.NET_RealPlayType.NET_RType_Realplay,deviceId,"1",lRealHandle.longValue(),null);
            realPlayHandlers.put(lRealHandle.longValue(), info);
        } else {
            System.err.printf("RealPlayByDataType Failed!Last Error[0x%x]\n", DHNetSdkLib.CLIENT_GetLastError());
        }
        return lRealHandle.longValue();
    }

再通过WebSocketRealDataCallback implements NetSDKLib.fRealDataCallBackEx来获取设备流数据的回调。

public class WebSocketRealDataCallback implements NetSDKLib.fRealDataCallBackEx {
    @Autowired
    private WebSocketServer server;

    @Override
    public void invoke(NetSDKLib.LLong lRealHandle, int dwDataType, Pointer pBuffer, int dwBufSize, int param, Pointer dwUser) {
        RealPlayInfo info = AutoRegisterEventModule.findRealPlayInfo(lRealHandle.longValue());
        if (info != null && info.getLoginHandler() != 0) {
            //过滤码流
            byte[] buffer = pBuffer.getByteArray(0, dwBufSize);
            if (info.getEmDataType() == 0 || info.getEmDataType() == 3) {
                //选择私有码流或mp4码流,拉流出的码流都是私有码流
                if (dwDataType == 0) {
                    //sendBuffer(buffer, lRealHandle.longValue());
                }
            } else if ((dwDataType - 1000) == info.getEmDataType()) {
                sendBuffer(pBuffer.getByteArray(0, dwBufSize), lRealHandle.longValue());
            }
        }
    }

    private void sendBuffer(byte[] bytes, long realPlayHandler) {
        /**
         * 发送流数据
         * 使用pBuffer.getByteBuffer(0,dwBufSize)得到的是一个指向native pointer的ByteBuffer对象,其数据存储在native,
         * 而webSocket发送的数据需要存储在ByteBuffer的成员变量hb,使用pBuffer的getByteBuffer得到的ByteBuffer其hb为null
         * 所以,需要先得到pBuffer的字节数组,手动创建一个ByteBuffer
         */
        ByteBuffer buffer = ByteBuffer.wrap(bytes);
        server.forwardDataFlow(realPlayHandler, buffer);
    }
}

设备流数据再通过websocket推送给前端,完成闭环。

注意事项

1.需要告诉摄像头回调的流形式需要是FLV格式 NetSDKLib.EM_REAL_DATA_TYPE.EM_REAL_DATA_TYPE_FLV_STREAM;

2.前端如果报错

[TransmuxingController] > DemuxException: type = CodecUnsupported, info = Flv: Unsupported codec in video frame: 12

是因为FLV流的格式不对,因为FLV流要求视频编码为H264格式,声音为AAC格式。你需要检查网络摄像机视频设置视频码流设置是否正确。

 3.请务必阅读大华《NetSDK_JAVA开发_FAQ》开发文档。特别是开发环境windows,服务器环境liunx的情况。

最后前端效果图

下载资源

https://download.csdn.net/download/qq_37529783/88466488

基于VueSpringBoot和MySQL的音乐播放管理系统可通过以下方式实现: 1. 使用Vue框架搭建前端界面:前端界面可以包括登录注册页面、音乐列表页面、音乐播放页面等。通过Vue的组件化开发,可以方便地实现页面的可复用性和交互性。 2. 使用SpringBoot框架搭建后端服务:后端服务主要负责接受前端的请求并进行处理,包括用户登录注册、音乐列表获取、音乐播放控制等。通过SpringBoot的注解驱动开发,可以简化开发流程。 3. 使用MySQL数据库存储数据:在MySQL中创建音乐、用户等相关数据表,通过SpringBoot的JPA或MyBatis等持久层框架实现与数据库的交互。例如,将音乐信息存储在音乐表中,包括音乐名称、歌手、时长等。 4. 用户登录注册功能:前端通过Vue的表单组件收集用户提供的账号和密码等信息,将其发送给后端进行验证。后端通过操作MySQL数据库中的用户表,判断用户是否存在以及密码是否正确,返回验证结果给前端。 5. 音乐列表获取功能:前端通过发送请求到后端的接口获取音乐列表数据。后端通过查询MySQL数据库中的音乐表,将查询结果返回给前端。前端可以通过Vue的列表渲染功能将音乐列表展示出来。 6. 音乐播放控制功能:前端通过点击音乐列表中的音乐项,发送请求到后端的接口以获取音乐的播放地址。后端通过查询MySQL数据库中的音乐表,将音乐的播放地址返回给前端。前端可以通过Vue的音乐播放组件实现音乐的播放控制,包括播放、暂停、调整音量等操作。 通过以上步骤,可以实现一个基于VueSpringBoot和MySQL的音乐播放管理系统。该系统可以实现用户登录注册、音乐列表获取和音乐播放控制等功能,为用户提供清晰便捷的音乐播放体验。
评论 88
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值