websocket+WebRTC实现视频通话

wssocket.js,用于创建websocket,发送到后端信令服务器,并接收信令服务器转发过来的消息。

let closeCallBack = null;
let isConnect = false; //连接标识 避免重复连接
let rec = null;
let lastConnectTime = new Date(); // 最后一次连接时间
let socketTask = null;

// 生成随机ID
function generateUserId() {
	return Math.floor(Math.random() * 9000 + 1000).toString();
}

let connect = (wsurl, token) => {
	if (isConnect) {
		return;
	}
	lastConnectTime = new Date();
	socketTask = uni.connectSocket({
		url: wsurl,
		protocols: [token],
		success: (res) => {
			console.log("websocket连接成功");
		},
		fail: (e) => {
			console.log(e);
			console.log("websocket连接失败,10s后重连");
			setTimeout(() => {
				connect();
			}, 10000)
		}
	});

	socketTask.onOpen((res) => {
		isConnect = true;
		console.log('WebSocket 连接已建立');
	})

	socketTask.onMessage((res) => {
		console.log('收到 WebSocket 消息:', event.data);
		const message = JSON.parse(event.data);
		handleSignalingMessage(message);
	})

	socketTask.onClose((res) => {
		console.log('WebSocket 连接已关闭,3秒后重试');
		reconnect(wsurl, token)
	})

	socketTask.onError((e) => {
		console.log("ws错误:", e)
		close();
		isConnect = false;
		closeCallBack && closeCallBack({
			code: 1006
		});
	})
}

function handleSignalingMessage(message) {
	console.log('收到信令消息:', message);
	switch (message.type) {
		case 'call-invitation':
			console.log('收到通话邀请 from:', message.from);
			uni.navigateTo({
				url: "/subpages/videocallrev/videocallrev?currentCallId=" + message.from
			})
			break;
		case 'call-accepted':
			console.log('对方同意通话邀请 from:', message.from);
			uni.navigateTo({
				url: `/subpages/videocall/videocallh5?currentCallId=${message.from}&isCaller=true`
			})
			break;
		case 'call-rejected':
			console.log('对方拒绝通话邀请from:', message.from);
			handleCallRejected(message.from);
			break;
		case 'offer':
			uni.$emit('handleOffer', {
				fromId: message.from,
				data: message.data
			})
			break;
		case 'answer':
			uni.$emit('handleAnswer', {
				fromId: message.from,
				data: message.data
			})
			break;
		case 'ice':
			uni.$emit('handleIceCandidate', {
				fromId: message.from,
				data: message.data
			})
			break;
		case 'call-ended':
			uni.$emit('handleCallEnded', {
				fromId: message.from
			})
			break;
	}
}

//定义重连函数
let reconnect = (wsurl, accessToken) => {
	console.log("尝试重新连接");
	if (isConnect) {
		return;
	}
	// 延迟10秒重连  避免过多次过频繁请求重连
	let timeDiff = new Date().getTime() - lastConnectTime.getTime()
	let delay = timeDiff < 10000 ? 10000 - timeDiff : 0;
	rec && clearTimeout(rec);
	rec = setTimeout(function() {
		connect(wsurl, accessToken);
	}, delay);
};

//设置关闭连接
let close = (code) => {
	if (!isConnect) {
		return;
	}
	socketTask.close({
		code: code,
		complete: (res) => {
			console.log("关闭websocket连接");
			isConnect = false;
		},
		fail: (e) => {
			console.log("关闭websocket连接失败", e);
		}
	});
};

let sendMessage = (message) => {
	socketTask.send({
		data: JSON.stringify(message)
	})
}

// 将方法暴露出去
export {
	connect,
	reconnect,
	close,
	sendMessage
}

SignalingWebSocket.java,接收前端websocket连接,接收websocket的send方法传递的数据,解析数据后转发给接收方。

package org.jeecg.modules.webrtc;

import com.alibaba.fastjson.JSON;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

@Slf4j
@Component
@ServerEndpoint("/signaling/{userId}")
public class SignalingWebSocket {

    // 存储所有在线用户的WebSocket会话
    private static final ConcurrentHashMap<String, Session> users = new ConcurrentHashMap<>();

    // 存储用户ID和名称的映射
    private static final ConcurrentHashMap<String, String> userNames = new ConcurrentHashMap<>();

    // 添加消息发送锁
    private static final ConcurrentHashMap<Session, ReentrantLock> sessionLocks = new ConcurrentHashMap<>();

    private String userId;


    @OnOpen
    public void onOpen(Session session, @PathParam(value = "userId") String userId) {
        users.put(userId,session);
        sessionLocks.put(session, new ReentrantLock());
        log.info("新的WebSocket连接: {}", session.getId());
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        try {
            WebRTCMessage webRTCMessage = JSON.parseObject(message, WebRTCMessage.class);
            log.info("收到消息 from: {}, to: {}, type: {}",
                    webRTCMessage.getFrom(),
                    webRTCMessage.getTo(),
                    webRTCMessage.getType());

            switch (webRTCMessage.getType()) {
                case "join":
                    handleJoin(webRTCMessage, session);
                    break;
                case "chat":
                    log.info("转发聊天消息: {} -> {}", webRTCMessage.getFrom(), webRTCMessage.getTo());
                    forwardMessage(webRTCMessage);
                    break;
                case "whiteboard":
                    log.info("转发白板数据: {} -> {}, action: {}",
                            webRTCMessage.getFrom(),
                            webRTCMessage.getTo(),
                            webRTCMessage.getAction());
                    forwardMessage(webRTCMessage);
                    break;
                case "call-invitation":
                case "call-accepted":
                case "call-rejected":
                case "offer":
                case "answer":
                case "ice":
                case "call-ended":
                    forwardMessage(webRTCMessage);
                    break;
                case "leave":
                    handleLeave(webRTCMessage);
                    break;
                default:
                    log.warn("未知的消息类型: {}", webRTCMessage.getType());
            }
        } catch (Exception e) {
            log.error("处理消息时发生错误: {}", message, e);
        }
    }

    @OnClose
    public void onClose(Session session) {
        if (userId != null) {
            users.remove(userId);
            userNames.remove(userId);
            // 通知其他用户该用户已离开
            broadcastUserList();
        }
        sessionLocks.remove(session);
        log.info("WebSocket连接关闭: {}", session.getId());
    }

    @OnError
    public void onError(Session session, Throwable error) {
        log.error("WebSocket错误", error);
    }

    private void handleJoin(WebRTCMessage message, Session session) {
        userId = message.getFrom();
        users.put(userId, session);
        userNames.put(userId, message.getName());

        log.info("用户加入: {}, 当前在线用户: {}", userId, userNames.keySet());

        // 发送当前在线用户列表给新用户
        sendUserList(session);
        // 广播新用户加入消息给其他用户
        broadcastUserList();
    }

    private void handleLeave(WebRTCMessage message) {
        String leavingUserId = message.getFrom();
        Session session = users.get(leavingUserId);
        if (session != null) {
            users.remove(leavingUserId);
            userNames.remove(leavingUserId);
            broadcastUserList();
        }
    }

    private void forwardMessage(WebRTCMessage message) {
        Session targetSession = users.get(message.getTo());
        if (targetSession != null && targetSession.isOpen()) {
            log.info("正在转发消息 type: {} from: {} to: {}",
                    message.getType(),
                    message.getFrom(),
                    message.getTo());
            sendMessageToSession(targetSession, message);
        } else {
            log.warn("目标用户不在线或会话已关闭: {}", message.getTo());
        }
    }

    private void sendUserList(Session session) {
        WebRTCMessage message = new WebRTCMessage();
        message.setType("users");
        message.setData(userNames);
        sendMessageToSession(session, message);
    }

    private void broadcastUserList() {
        WebRTCMessage message = new WebRTCMessage();
        message.setType("users");
        message.setData(userNames);

        users.values().forEach(session -> {
            sendMessageToSession(session, message);
        });
    }

    private void sendMessageToSession(Session session, WebRTCMessage message) {
        if (session == null || !session.isOpen()) {
            return;
        }

        ReentrantLock lock = sessionLocks.get(session);
        if (lock != null) {
            lock.lock();
            try {
                String messageJson = JSON.toJSONString(message);
                session.getBasicRemote().sendText(messageJson);
            } catch (IOException e) {
                log.error("发送消息失败", e);
            } finally {
                lock.unlock();
            }
        }
    }
}

sendvideocall.vue,呼叫方发起视频通话邀请。

<template>
	<view class="content">
		<image :src="getFile(friendInfo.headImage)" style="height: 100vh;" mode="heightFix"></image>
		<view class="ftbg">
			<view style="margin-top: 280rpx;">
				<u-avatar :src="getFile(friendInfo.headImage)" size="100" style="margin: 10rpx;"
					mode="square"></u-avatar>
			</view>
			<view style="margin-top: 10rpx;color: white;font-size: 28rpx;">
				{{ friendInfo.nickName }}
			</view>
			<view style="margin-top: 130rpx;color: white;">
				正在等待对方回应...
			</view>
		</view>

		<view
			style="position: fixed;bottom: 100rpx;left: 0rpx;width: 100%;display: flex;justify-content: center;align-items: center;">
			<image @click="cancel" src="/static/images/icon_guad.png"
				style="width: 120rpx;height: 120rpx;border-radius: 60rpx;" mode=""></image>
		</view>
	</view>
</template>

<script>
	import {
		mapGetters,
	} from 'vuex';

	export default {
		data() {
			return {
				id: '',
				mid: '',
				friend: "",
				currentCallId: '',
				userId: "",
			}
		},
		computed: {
			...mapGetters(['findFriend']),
			friendInfo() {
				return this.findFriend(this.currentCallId);
			},
		},
		onBackPress(options) {
			if (options.from && options.from == 'backbutton') {
				return true
			}
			return false
		},
		onLoad({
			currentCallId
		}) {
			this.currentCallId = currentCallId
			this.userId = this.$store.state.userInfo.id
			this.sendCallInvitation()
		},
		methods: {
			sendCallInvitation() {
				console.log('发送通话邀请给:', this.currentCallId);
				this.$socket.sendMessage({
					type: 'call-invitation',
					from: this.userId,
					to: this.currentCallId,
				});
			},

			cancel: async function() {
				this.$socket.sendMessage({
					type: 'call-ended',
					from: this.userId,
					to: this.currentCallId
				});
				uni.navigateBack()
			},
		}
	}
</script>

<style scoped>
	.content {
		width: 100%;
		height: 100vh;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		background: linear-gradient(to bottom, #666, #222);
	}

	.ftbg {
		width: 100%;
		height: 100vh;
		top: 0rpx;
		left: 0rpx;
		background: #00000099;
		backdrop-filter: blur(20px);
		position: fixed;
		display: flex;
		flex-direction: column;
		align-items: center;
	}
</style>

videocallrev.vue,接收方收到通话邀请后跳转的页面,在此页面可以同意或拒绝通话。

<template>
	<view class="content">
		<image :src="getFile(friendInfo.headImage)" style="height: 100vh;" mode="heightFix"></image>
		<view class="ftbg">
			<view style="margin-top: 280rpx;">
				<u-avatar :src="getFile(friendInfo.headImage)" size="100" style="margin: 10rpx;"
					mode="square"></u-avatar>
			</view>
			<view style="margin-top: 10rpx;color: white;font-size: 28rpx;">
				{{friendInfo.nickName}}
			</view>
			<view style="margin-top: 130rpx;color: white;">
				邀请你视频通话
			</view>
		</view>

		<view
			style="position: fixed;bottom: 100rpx;left: 0rpx;width: 100%;display: flex;justify-content: center;align-items: center;">
			<image @click="cancel" src="/static/images/icon_guad.png"
				style="width: 120rpx;height: 120rpx;border-radius: 60rpx;" mode=""></image>
			<image @click="agree" src="/static/images/icon_hup.png"
				style="width: 120rpx;height: 120rpx;border-radius: 60rpx;margin-left: 240rpx;" mode=""></image>
		</view>
	</view>
</template>

<script>
	import {
		mapGetters
	} from 'vuex';
 
	export default {
		data() {
			return {
				currentCallId: '',
				mid: '',
				message: ''
			}
		},
		computed: {
			...mapGetters(['findFriend']),
			friendInfo() {
				return this.findFriend(this.currentCallId);
			},
		},
		onBackPress(options) {
			if (options.from && options.from == 'backbutton') {
				return true
			}
			return false
		},
		onLoad({
			currentCallId
		}) {
			this.currentCallId = currentCallId
		},
		methods: {
			cancel: async function() {
				const ret = await this.$u.put(`chat/chat/videocall/refuse/${this.mid}`)
				pub("stopmusic", {})
				this.$store.commit(`common/setStatus`, "normal")
				uni.navigateBack()
			},
			agree: async function() {
				// #ifdef APP
				uni.redirectTo({
					url: `/subpages/videocall/videocall?currentCallId=${this.currentCallId}&&isCaller=false`
				})
				// #endif
				// #ifdef H5
				uni.redirectTo({
					url: `/subpages/videocall/videocallh5?currentCallId=${this.currentCallId}&&isCaller=false`
				})
				// #endif
			},
		}
	}
</script>

<style scoped>
	.content {
		width: 100%;
		height: 100vh;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		background: linear-gradient(to bottom, #666, #222);
	}

	.ftbg {
		width: 100%;
		height: 100vh;
		top: 0rpx;
		left: 0rpx;
		background: #00000099;
		backdrop-filter: blur(20px);
		position: fixed;
		display: flex;
		flex-direction: column;
		align-items: center;
	}
</style>

videocallh5.vue,接收方同意通话邀请后,呼叫方和接收方都跳转的页面。

<template>
	<view class="content">
		<view style="" class="topvg">
			<view style="width: 100%;display: flex;justify-content: space-between;padding: 50rpx;">
				<view style="display: flex;align-items: center;">
					<view @click="changecamera" class="btnitem" style="">
						<image src="/static/images/qqhsxt.png" style="width: 60rpx;" mode="widthFix"></image>
					</view>
					<view @click="showself = !showself" class="btnitem" style="margin-left: 20rpx;">
						<image src="/static/images/sshow.png" style="width: 60rpx;" mode="widthFix"></image>
					</view>
					<view v-if="!uservddata.audio" class="btnitem" style="margin-left: 20rpx;">
						<image src="/static/images/dfab.png" style="width: 60rpx;" mode="widthFix"></image>
					</view>
				</view>
				<view style="font-size: 28rpx;color: #bec7df;">

				</view>
				<view style="width: 60rpx;">

				</view>
			</view>
		</view>
		<view v-show="showself"
			style="width: 240rpx;height: 400rpx; position: fixed;right: 40rpx;top: 130rpx;z-index: 101;border-radius: 16rpx;">
			<bgyx-video-item :uid="$store.state.userInfo.id" :name="$store.state.userInfo.realname" radius="16rpx"
				:id="`bgyx_video_1`" :src="myvddata.stream" status="play" :video="myvddata.video"
				:audio="myvddata.audio" :muted="true" />
		</view>
		<view style="width: 100%;display: flex;flex-wrap: wrap;flex: 1;overflow-y: hidden;">
			<bgyx-video-item :uid="id" :name="name" :id="`bgyx_video_0`" :src="uservddata.stream" status="play"
				:video="uservddata.video" :audio="uservddata.audio" />
		</view>

		<view style="" class="btmvg">
			<view style="display: flex;width: 100%;align-items: center;justify-content: center;margin-bottom: 60rpx;">
				<view @click="changeaudio(false)" v-if="myvddata.audio" class="btnitem" style="">
					<image src="/static/images/mmic.png" style="width: 60rpx;" mode="widthFix"></image>
				</view>
				<view @click="changeaudio(true)" v-if="!myvddata.audio" class="btnitem" style="background: #ff5e5e66;">
					<image src="/static/images/mmic.png" style="width: 60rpx;" mode="widthFix"></image>
				</view>

				<view @click="exit" class="btnitem"
					style="margin-left: 80rpx;background: #ff5e5e;width: 140rpx;height: 140rpx;">
					<image src="/static/images/hhdown.png" style="width: 70rpx;" mode="widthFix"></image>
				</view>

				<view @click="changevideo(false)" v-if="myvddata.video" class="btnitem" style="margin-left: 80rpx;">
					<image src="/static/images/ccamera.png" style="width: 60rpx;" mode="widthFix"></image>
				</view>
				<view @click="changevideo(true)" v-if="!myvddata.video" class="btnitem"
					style="margin-left: 80rpx;background: #ff5e5e66;">
					<image src="/static/images/ccamera.png" style="width: 60rpx;" mode="widthFix"></image>
				</view>
			</view>
		</view>
	</view>
</template>

<script>
	import {
		mapGetters
	} from 'vuex';

	import BgyxVideoItem from '@/subpages/components/im/bgyx-video-item.vue'

	export default {
		components: {
			BgyxVideoItem
		},
		data() {
			return {
				id: '',
				name: '',
				src: '',
				showself: true,
				myvddata: '',
				uservddata: '',
				audio: true,
				video: true,
				isCaller: false,
				userId: "",
				currentCallId: "",
				peerConnection: null,
				configuration: {
					iceServers: [{
						urls: [
							'stun:stun.l.google.com:19302',
							'stun:stun1.l.google.com:19302'
						]
					}],
					iceTransportPolicy: 'all',
					bundlePolicy: 'max-bundle',
					rtcpMuxPolicy: 'require',
					iceCandidatePoolSize: 0
				}
			}
		},
		computed: {
			...mapGetters(['findFriend']),
			friendInfo() {
				return this.findFriend(this.currentCallId);
			},
		},
		onBackPress(options) {
			if (options.from && options.from == 'backbutton') {
				return true
			}
			return false
		},
		onLoad({
			currentCallId,
			isCaller
		}) {
			this.currentCallId = currentCallId
			this.userId = this.$store.state.userInfo.id
			this.isCaller = JSON.parse(isCaller)
			this.name = this.friendInfo.nickName
			if (this.isCaller) { //呼叫者
				this.publishStream({
					video: true,
					audio: true,
					videoType: 'face'
				})
			} else { //被呼叫者
				this.$socket.sendMessage({
					type: 'call-accepted',
					from: this.userId,
					to: this.currentCallId
				});
			}

			//被呼叫者接收offer
			uni.$on('handleOffer', (data) => {
				this.handleOffer(data.fromId, data.data)
			})

			//呼叫者接收被呼叫者创建的应答
			uni.$on('handleAnswer', (data) => {
				this.handleAnswer(data.fromId, data.data)
			})

			//双方交换ice
			uni.$on('handleIceCandidate', (data) => {
				this.handleIceCandidate(data.fromId, data.data)
			})

			uni.$on('handleCallEnded', (data) => {
				this.handleCallEnded(data.fromId)
			})
		},
		methods: {
			exit() {
				this.$socket.sendMessage({
					type: "call-ended",
					from: this.userId,
					to: this.currentCallId,
				})
				uni.navigateBack()
			},

			handleCallEnded(fromId) {
				console.log("对方结束了通话 from:", fromId)
				if (fromId == this.currentCallId) {
					uni.showToast({
						title: "对方结束了通话",
						icon: "none"
					})
				}
			},

			// 添加处理 answer 的函数
			async handleAnswer(fromId, answer) {
				console.log('收到应答 from:', fromId);
				const pc = this.peerConnection;
				if (pc) {
					await pc.setRemoteDescription(new RTCSessionDescription(answer));
				}
			},

			// 添加处理 ICE candidate 的函数
			async handleIceCandidate(fromId, candidate) {
				console.log('收到candidate from:', fromId);
				const pc = this.peerConnection;
				if (pc) {
					await pc.addIceCandidate(new RTCIceCandidate(candidate));
				}
			},
			// 修改 handleOffer 函数
			async handleOffer(fromId, offer) {
				console.log('收到offer from:', fromId);
				const pc = new RTCPeerConnection(this.configuration);
				this.peerConnection = pc
				const localStream = await this.getStreamLoc({
					audio: true,
					video: true,
				})
				localStream.getTracks().forEach(function(track) {
					pc.addTrack(track);
				});

				pc.onicecandidate = async (event) => {
					if (event.candidate) { // 移除 host 限制
						console.log('handleOffer发送 ICE 候选者:', event.candidate);
						this.$socket.sendMessage({
							type: 'ice',
							from: this.userId,
							to: fromId,
							data: event.candidate
						});
					}
				};

				let remoteStream = new MediaStream();
				pc.ontrack = function(event) {
					remoteStream.addTrack(event.track);
				};

				console.log('设置远程offer描述:', offer);
				await pc.setRemoteDescription(new RTCSessionDescription(offer));

				const answer = await pc.createAnswer();
				console.log('创建answer:', answer);

				await pc.setLocalDescription(answer);

				const mydata = {
					id: this.userId,
					pc,
					stream: localStream,
					audio: true,
					video: true,
				};
				this.myvddata = mydata

				const vdata = {
					id: this.currentCallId,
					pc,
					stream: remoteStream,
					audio: true,
					video: true,
				};
				this.uservddata = vdata

				this.$socket.sendMessage({
					type: 'answer',
					from: this.userId,
					to: fromId,
					data: answer
				});
			},

			async publishStream(options) {
				const pc = new RTCPeerConnection(this.configuration);
				this.peerConnection = pc
				const localStream = await this.getStreamLoc({
					audio: true,
					video: true,
				})
				localStream.getTracks().forEach(function(track) {
					pc.addTrack(track);
				});

				let remoteStream = new MediaStream();
				pc.ontrack = function(event) {
					remoteStream.addTrack(event.track);
				};

				let offer = await pc.createOffer();
				await pc.setLocalDescription(offer);
				console.log('创建offer:', offer);

				pc.onicecandidate = async (event) => {
					if (event.candidate) { // 移除 host 限制
						console.log('publishStream发送 ICE 候选者:', event.candidate);
						this.$socket.sendMessage({
							type: 'ice',
							from: this.userId,
							to: this.currentCallId,
							data: event.candidate
						});
					}
				};

				const myvdata = {
					id: this.userId,
					pc,
					stream: localStream,
					audio: true,
					video: true,
				};

				this.myvddata = myvdata

				const uvdata = {
					id: this.currentCallId,
					pc,
					stream: remoteStream,
					audio: true,
					video: true,
				};

				this.uservddata = uvdata

				this.$socket.sendMessage({
					type: 'offer',
					from: this.userId,
					to: this.currentCallId,
					data: offer
				});
			},

			getStreamLoc: async function(constraints) {
				if (navigator.mediaDevices.getUserMedia) {
					console.log('最新的标准API', navigator.mediaDevices.getUserMedia);
					const rs = await navigator.mediaDevices.getUserMedia(constraints)
					return rs
				} else if (navigator.webkitGetUserMedia) {
					console.log('webkit核心浏览器');
					const rs = await navigator.webkitGetUserMedia(constraints)
					return rs
				} else if (navigator.mozGetUserMedia) {
					console.log('firfox浏览器');
					const rs = await navigator.mozGetUserMedia(constraints);
					return rs
				} else if (navigator.getUserMedia) {
					console.log('旧版API');
					const rs = await navigator.getUserMedia(constraints);
					return rs
				} else {
					const rs = await navigator.mediaDevices.getUserMedia(constraints)
					return rs
				}
			},
		}
	}
</script>


<style scoped>
	.content {
		display: flex;
		flex-direction: column;
		align-items: center;
		width: 100%;
		height: 100vh;
		justify-content: space-between;
		overflow-y: hidden;
	}

	.btmvg {
		width: 100%;
		display: flex;
		height: 40%;
		background: linear-gradient(to bottom, #33333300, #111111);
		position: fixed;
		left: 0rpx;
		bottom: 0rpx;
		color: white;
		align-items: flex-end;
	}

	.topvg {
		width: 100%;
		display: flex;
		height: 40%;
		background: linear-gradient(to bottom, #111111, #33333300);
		position: fixed;
		left: 0rpx;
		top: 0rpx;
		color: white;
		align-items: flex-start;
		z-index: 99;
	}

	.btnitem {
		width: 120rpx;
		height: 120rpx;
		display: flex;
		align-items: center;
		justify-content: center;
		border-radius: 160rpx;
		background: #cfcfcf55;
	}
</style>

效果展示:

一对一视频邀请:

一对一视频通话:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值