依是一套全部开源的快速开发平台,毫无保留给个人及企业免费使用。
微言聊天室是基于前后端分离,采用SpringBoot+Vue框架开发的网页版聊天室。使用了Spring Security安全框架进行密码的加密存储和登录登出等逻辑的处理,以WebSocket+Socket.js+Stomp.js实现消息的发送与接收,监听。搭建FastDFS文件服务器用于保存图片,使用EasyExcel导出数据,使用Vue.js结合Element UI进行显示弹窗和数据表格分页等功能,以及整个系统的界面进行UI设计,并且使用MyBatis结合数据库MySQL进行开发。最后使用了Nginx进行部署前后端分离项目。
功能实现:群聊,单聊,邮件发送,emoji表情发送,图片发送,用户管理,群聊记录管理,Excel的导出。关注Java项目分享
客户端界面-群聊主界面
客户端界面-私聊界面
管理端界面-用户管理
管理端界面-群聊消息管理
点赞转发本文后私信【0724】四个数字即可获取前后端代码和操作教学视频
前端代码
后端代码
操作视频
文由作者“大白菜”分享,有较多修订和改动。注意:本系列是给IM初学者的文章,IM老油条们还望海涵,勿喷!
接上两篇《IM系统设计篇》、《编码实践篇(单聊功能)》,本篇主要讲解的是通过实战编码实现IM的群聊功能,内容涉及群聊技术实现原理、编码实践等知识。
建议你在阅读本文之前,务必先读本系列的前两篇《IM系统设计篇》、《编码实践篇(单聊功能)》,在着重理解IM系统的理论设计思路之后,再来阅读实战代码则效果更好。
最后,在开始本文之前,请您务必提前了解Netty的相关基础知识,可从本系列首篇《IM系统设计篇》中的“知识准备”一章开始。
本文是系列文章的第3篇,以下是系列目录:
在上篇《编码实践篇(单聊功能)》中,我们主要实现了IM的单聊功能,本节主要是实现IM群聊功能。
本篇涉及的群聊核心功能,大致如下所示:
其实群聊和单聊,整体上原理是一样的,只是做了一下细节上的升级。
在首篇《IM系统设计篇》的“6、IM群聊思路设计”设计部分也做了详细的说明了。
群聊的大概流程就是:根据群组 ID 查找到所有的成员集合,然后再遍历找到每个成员对应的连接通道。
具体的群聊架构思路如下图:
补充说明:因为本系列文章主要目的是引导IM初学者在基于Netty的情况下,如何一步一步从零写出IM的逻辑和思维能力,因而为了简化编码实现,本篇中编码实现的客户端都是基于控制台实现的(希望不要被嫌弃),因为理解技术的本质显然比炫酷的外在表现形式更为重要。
用户登录效果图:
服务端映射关系的管理,分别是:
主要通过两个 Map 去维护,具体如下:
public class ServerChatGroupHandler extends ChannelInboundHandlerAdapter {
private static Map<Integer, Channel> map=new HashMap<Integer, Channel>();
private static Map<Integer, Group> groups=new HashMap<Integer, Group>();
}
//组和成员列表关系实体
@Data
public class Group implements Serializable {
private String groupName;
private List<GroupMember> members=new ArrayList<GroupMember>();
}
//成员和连接通道的关系实体
public class GroupMember implements Serializable {
private Integer userid;
private Channel channel;
}
我们准备好相应的实体,以及实体和指令的映射关系,具体如下所示:
private static Map<Byte, Class<? extends BaseBean>> map=new HashMap<Byte,Class<? extends BaseBean>>();
static{
//登录的请求和响应实体
map.put(1, LoginReqBean.class);
map.put(2, LoginResBean.class);
//创建群组的请求和响应实体
map.put(3, GroupCreateReqBean.class);
map.put(4, GroupCreateResBean.class);
//查看群组的请求和响应实体
map.put(5, GroupListReqBean.class);
map.put(6, GroupListResBean.class);
//加入群组的请求和响应实体
map.put(7,GroupAddReqBean.class);
map.put(8,GroupAddResBean.class);
//退出群组的请求和响应实体
map.put(9,GroupQuitReqBean.class);
map.put(10,GroupQuitResBean.class);
//查看成员列表的请求和响应实体
map.put(11,GroupMemberReqBean.class);
map.put(12,GroupMemberResBean.class);
//发送响应的实体(发送消息、发送响应、接受消息)
map.put(13,GroupSendMsgReqBean.class);
map.put(14,GroupSendMsgResBean.class);
map.put(15,GroupRecMsgBean.class);
}
通过下面这张图,能看的更清晰一些:
IM群聊功能的实现,我们需要两个两个业务 Handler:
客户端 Handler,主要是通过判断实体类型来做不同的业务操作,当然也可以使用 SimpleChannelInboundHandler 去进行 Handler 拆分。
public class ClientChatGroupHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
//在链接就绪时登录
login(ctx.channel());
}
//主要是“接受服务端”的响应信息
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if(msg instanceof LoginResBean){
LoginResBean res=(LoginResBean) msg;
System.out.println("登录响应:"+res.getMsg());
if(res.getStatus()==0){
//登录成功
//1.给通道绑定身份
ctx.channel().attr(AttributeKey.valueOf("userid")).set(res.getUserid());
//2.显示操作类型【请看下面】
deal(ctx.channel());
}else{
//登录失败,继续登录
login(ctx.channel());
}
}else if(msg instanceof GroupCreateResBean){
GroupCreateResBean res=(GroupCreateResBean)msg;
System.out.println("创建响应群组:"+res.getMsg());
}else if(msg instanceofGroupListResBean){
GroupListResBean res=(GroupListResBean)msg;
System.out.println("查看群组列表:"+res.getLists());
}elseif(msg instanceofGroupAddResBean){
GroupAddResBean res=(GroupAddResBean)msg;
System.out.println("加入群组响应:"+res.getMsg());
}elseif(msg instanceof GroupQuitResBean){
GroupQuitResBean res=(GroupQuitResBean)msg;
System.out.println("退群群组响应:"+res.getMsg());
}else if(msg instanceof GroupMemberResBean){
GroupMemberResBean res=(GroupMemberResBean)msg;
if(res.getCode()==1){
System.out.println("查看成员列表:"+res.getMsg());
}else{
System.out.println("查看成员列表:"+res.getLists());
}
}else if(msg instanceof GroupSendMsgResBean){
GroupSendMsgResBean res=(GroupSendMsgResBean)msg;
System.out.println("群发消息响应:"+res.getMsg());
}else if(msg instanceof GroupRecMsgBean){
GroupRecMsgBean res=(GroupRecMsgBean)msg;
System.out.println("收到消息fromuserid="+
res.getFromuserid()+
",msg="+res.getMsg());
}
}
}
通过子线程循环向输出控制台输出操作类型的方法,以下方法目前都是空方法,下面将详细讲解。
private void deal(final Channel channel){
final Scanner scanner=new Scanner(System.in);
new Thread(new Runnable() {
public void run() {
while(true){
System.out.println("请选择类型:0创建群组,1查看群组,2加入群组,3退出群组,4查看群成员,5群发消息");
int type=scanner.nextInt();
switch(type){
case 0:
createGroup(scanner,channel);
break;
case 1:
listGroup(scanner,channel);
break;
case 2:
addGroup(scanner,channel);
break;
case 3:
quitGroup(scanner,channel);
break;
case 4:
listMembers(scanner,channel);
break;
case 5:
sendMsgToGroup(scanner,channel);
break;
default:
System.out.println("输入的类型不存在!");
}
}
}
}).start();
}
服务端 Handler,主要是通过判断实体类型来做不同的业务操作,当然也可以使用 SimpleChannelInboundHandler 去进行 Handler 拆分。
以下方法目前都是空方法,下面将详细讲解。
public class ServerChatGroupHandler extends ChannelInboundHandlerAdapter {
private static Map<Integer, Channel> map=new HashMap<Integer, Channel>();
private static Map<Integer, Group> groups=new HashMap<Integer, Group>();
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if(msg instanceof LoginReqBean) {
//登录
login((LoginReqBean) msg, ctx.channel());
}else if(msg instanceof GroupCreateReqBean){
//创建群组
createGroup((GroupCreateReqBean)msg,ctx.channel());
}else if(msg instanceof GroupListReqBean){
//查看群组列表
listGroup((GroupListReqBean)msg,ctx.channel());
}else if(msg instanceof GroupAddReqBean){
//加入群组
addGroup((GroupAddReqBean)msg,ctx.channel());
}else if(msg instanceof GroupQuitReqBean){
//退出群组
quitGroup((GroupQuitReqBean)msg,ctx.channel());
}else if(msg instanceof GroupMemberReqBean){
//查看成员列表
listMember((GroupMemberReqBean)msg,ctx.channel());
}else if(msg instanceof GroupSendMsgReqBean){
//消息发送
sendMsg((GroupSendMsgReqBean) msg,ctx.channel());
}
}
}
客户端请求:
private void createGroup(Scanner scanner,Channel channel){
System.out.println("请输入群组ID");
Integer groupId=scanner.nextInt();
System.out.println("请输入群组名称");
String groupName=scanner.next();
GroupCreateReqBean bean=new GroupCreateReqBean();
bean.setGroupId(groupId);
bean.setGroupName(groupName);
channel.writeAndFlush(bean);
}
服务端处理:
public class ServerChatGroupHandler extends ChannelInboundHandlerAdapter {
private static Map<Integer, Channel> map=new HashMap<Integer, Channel>();
private static Map<Integer, Group> groups=new HashMap<Integer, Group>();
private void createGroup(GroupCreateReqBean bean,Channel channel){
//定义一个响应实体
GroupCreateResBean res=new GroupCreateResBean();
//查询groups是否已经存在
Group group=groups.get(bean.getGroupId());
//判断是否已经存在
if(group==null){
//定义群组实体
Group g=new Group();
//定义一个集合,专门存储成员
List<GroupMember> members=new ArrayList<GroupMember>();
//属性赋值
g.setGroupName(bean.getGroupName());
g.setMembers(members);
//添加到Map里面
groups.put(bean.getGroupId(),g);
//响应信息
res.setCode(0);
res.setMsg("创建群组成功");
}else{
res.setCode(1);
res.setMsg("该群组已经存在!");
}
channel.writeAndFlush(res);
}
}
客户端请求:
private void listGroup(Scanner scanner,Channel channel){
GroupListReqBean bean=new GroupListReqBean();
bean.setType("list");
channel.writeAndFlush(bean);
}
服务端处理:
public class ServerChatGroupHandler extends ChannelInboundHandlerAdapter {
private static Map<Integer, Channel> map=new HashMap<Integer, Channel>();
private static Map<Integer, Group> groups=new HashMap<Integer, Group>();
private void listGroup(GroupListReqBean bean,Channel channel){
if("list".equals(bean.getType())){
//定义一个响应实体
GroupListResBean res=new GroupListResBean();
//定义一个集合
List<GroupInfo> lists=new ArrayList<GroupInfo>();
//变量groups Map集合
for(Map.Entry<Integer, Group> entry : groups.entrySet()){
Integer mapKey = entry.getKey();
Group mapValue = entry.getValue();
GroupInfo gi=new GroupInfo();
gi.setGroupId(mapKey);
gi.setGroupName(mapValue.getGroupName());
lists.add(gi);
}
//把集合添加到响应实体里面
res.setLists(lists);
//开始写到客户端
channel.writeAndFlush(res);
}
}
}
客户端请求:
private void addGroup(Scanner scanner,Channel channel){
System.out.println("请输入加入的群组ID");
int groupId=scanner.nextInt();
Integer userId=(Integer) channel.attr(AttributeKey.valueOf("userid")).get();
GroupAddReqBean bean=new GroupAddReqBean();
bean.setUserId(userId);
bean.setGroupId(groupId);
channel.writeAndFlush(bean);
}
服务端处理:
public class ServerChatGroupHandler extends ChannelInboundHandlerAdapter {
private static Map<Integer, Channel> map=new HashMap<Integer, Channel>();
private static Map<Integer, Group> groups=new HashMap<Integer, Group>();
private void addGroup(GroupAddReqBean bean,Channel channel){
GroupAddResBean res=new GroupAddResBean();
//1.根据“群组ID”获取对应的“组信息”
Group group=groups.get(bean.getGroupId());
//2.“群组”不存在
if(group==null){
res.setCode(1);
res.setMsg("groupId="+bean.getGroupId()+",不存在!");
channel.writeAndFlush(res);
return;
}
//3.“群组”存在,则获取其底下的“成员集合”
List<GroupMember> members=group.getMembers();
boolean flag=false;
//4.遍历集合,判断“用户”是否已经存在了
for(GroupMember gm:members){
if(gm.getUserid()==bean.getUserId()){
flag=true;
break;
}
}
if(flag){
res.setCode(1);
res.setMsg("已经在群组里面,无法再次加入!");
}else{
//1.用户信息
GroupMember gm=new GroupMember();
gm.setUserid(bean.getUserId());
gm.setChannel(channel);
//2.添加到集合里面
members.add(gm);
//3.给“群组”重新赋值
group.setMembers(members);
res.setCode(0);
res.setMsg("加入群组成功");
}
channel.writeAndFlush(res);
}
}
客户端请求:
private void quitGroup(Scanner scanner,Channel channel){
System.out.println("请输入退出的群组ID");
int groupId=scanner.nextInt();
Integer userId=(Integer) channel.attr(AttributeKey.valueOf("userid")).get();
GroupQuitReqBean bean=new GroupQuitReqBean();
bean.setUserId(userId);
bean.setGroupId(groupId);
channel.writeAndFlush(bean);
}
服务端处理:
public class ServerChatGroupHandler extends ChannelInboundHandlerAdapter {
private static Map<Integer, Channel> map=new HashMap<Integer, Channel>();
private static Map<Integer, Group> groups=new HashMap<Integer, Group>();
private void quitGroup(GroupQuitReqBean bean,Channel channel){
GroupQuitResBean res=new GroupQuitResBean();
//1.根据“群组ID”获取对应的“组信息”
Group group=groups.get(bean.getGroupId());
if(group==null){
//2.群组不存在
res.setCode(1);
res.setMsg("groupId="+bean.getGroupId()+",不存在!");
channel.writeAndFlush(res);
return;
}
//3.群组存在,则获取其底下“成员集合”
List<GroupMember> members=group.getMembers();
//4.遍历集合,找到“当前用户”在集合的序号
int index=-1;
for(inti=0;i<members.size();i++){
if(members.get(i).getUserid()==bean.getUserId()){
index=i;
break;
}
}
//5.如果序号等于-1,则表示“当前用户”不存在集合里面
if(index==-1){
res.setCode(1);
res.setMsg("userid="+bean.getUserId()+",不存在该群组里面!");
channel.writeAndFlush(res);
return;
}
//6.从集合里面删除“当前用户”
members.remove(index);
//7.给“群组”的“成员列表”重新赋值
group.setMembers(members);
res.setCode(0);
res.setMsg("退出群组成功");
channel.writeAndFlush(res);
}
}
客户端请求:
private void listMembers(Scanner scanner,Channel channel){
System.out.println("请输入群组ID:");
int groupId=scanner.nextInt();
GroupMemberReqBean bean=new GroupMemberReqBean();
bean.setGroupId(groupId);
channel.writeAndFlush(bean);
}
服务端处理:
public class ServerChatGroupHandler extends ChannelInboundHandlerAdapter {
private static Map<Integer, Channel> map=new HashMap<Integer, Channel>();
private static Map<Integer, Group> groups=new HashMap<Integer, Group>();
private void listMember(GroupMemberReqBean bean,Channel channel){
GroupMemberResBean res=new GroupMemberResBean();
List<Integer> lists=new ArrayList<Integer>();
//1.根据“群组ID”获取对应的“组信息”
Group group=groups.get(bean.getGroupId());
if(group==null){
//2.查询的群组不存在
res.setCode(1);
res.setMsg("groupId="+bean.getGroupId()+",不存在!");
channel.writeAndFlush(res);
}else{
//3.群组存在,则变量其底层的成员
for(Map.Entry<Integer, Group> entry : groups.entrySet()){
Group g = entry.getValue();
List<GroupMember> members=g.getMembers();
for(GroupMember gm:members){
lists.add(gm.getUserid());
}
}
res.setCode(0);
res.setMsg("查询成功");
res.setLists(lists);
channel.writeAndFlush(res);
}
}
}
客户端请求:
private void sendMsgToGroup(Scanner scanner,Channel channel){
System.out.println("请输入群组ID:");
int groupId=scanner.nextInt();
System.out.println("请输入发送消息内容:");
String msg=scanner.next();
Integer userId=(Integer) channel.attr(AttributeKey.valueOf("userid")).get();
GroupSendMsgReqBean bean=new GroupSendMsgReqBean();
bean.setFromuserid(userId);
bean.setTogroupid(groupId);
bean.setMsg(msg);
channel.writeAndFlush(bean);
}
服务端处理:
public class ServerChatGroupHandler extends ChannelInboundHandlerAdapter {
private static Map<Integer, Channel> map=new HashMap<Integer, Channel>();
private static Map<Integer, Group> groups=new HashMap<Integer, Group>();
privatevoidsendMsg(GroupSendMsgReqBean bean,Channel channel){
GroupSendMsgResBean res=new GroupSendMsgResBean();
//1.根据“群组ID”获取对应的“组信息”
Group group=groups.get(bean.getTogroupid());
//2.给“发送人”响应,通知其发送的消息是否成功
if(group==null){
res.setCode(1);
res.setMsg("groupId="+bean.getTogroupid()+",不存在!");
channel.writeAndFlush(res);
return;
}else{
res.setCode(0);
res.setMsg("群发消息成功");
channel.writeAndFlush(res);
}
//3.根据“组”下面的“成员”,变量并且逐个推送消息
List<GroupMember> members=group.getMembers();
for(GroupMember gm:members){
GroupRecMsgBean rec=new GroupRecMsgBean();
rec.setFromuserid(bean.getFromuserid());
rec.setMsg(bean.getMsg());
gm.getChannel().writeAndFlush(rec);
}
}
}
本篇中涉及的功能点稍微有点多,主要是实现了群聊的几个核心功能,分别是:创建群组、查看群组列表、加入群组、退出群组、查看成员列表、群发消息。
这些功能经过拆解,看起来就不是那么复杂了,希望大家都可以亲自动手实现一遍,加深理解,提高学习效果。
实际上,真正的产品级IM中,群聊涉及的技术细节是非常多的,有兴趣可以详读下面这几篇:
[1] 手把手教你用Netty实现心跳机制、断线重连机制
[2] 自已开发IM很难?手把手教你撸一个Andriod版IM
[3] 基于Netty,从零开发一个IM服务端
[4] 拿起键盘就是干,教你徒手开发一套分布式IM系统
[5] 正确理解IM长连接、心跳及重连机制,并动手实现
[6] 手把手教你用Go快速搭建高性能、可扩展的IM系统
[7] 手把手教你用WebSocket打造Web端IM聊天
[8] 万字长文,手把手教你用Netty打造IM聊天
[9] 基于Netty实现一套分布式IM系统
[10] 基于Netty,搭建高性能IM集群(含技术思路+源码)
[11] SpringBoot集成开源IM框架MobileIMSDK,实现即时通讯IM聊天功能
学习交流:
- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》
- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)
(本文已同步发布于:http://www.52im.net/thread-3981-1-1.html)
TML实现文件夹的上传和下载,前端如何用HTML5实现分片上传GB级大文件,网页中实现文件上传下载的三种解决方案(推荐),HTML5实现文件批量上传组件,JQUERY 实现文件夹上传(保留目录结构),B/S大文件上传支持断点上传,WebService 大文件上传,断点续传分片,HTML+AJAX实现上传大文件问题,用HTML实现本地文件的上传,HTML5实现大文件上传,HTML5实现大文件分片上传思路,利用HTML5分片上传超大文件思路,
WEBUPLOADER 支持 超大上G,多附件上传,JS 大文件分割/分片上传,
百度WEBUPLOADER上传视频等大文件,WEBUPLOAD组件实现文件上传功能和下载功能,JS大文件切片上传,断点续传实现DEMO,前端上传大文件的解决方案,前端上传大文件处理(切片、断点续传),前端大文件上传优化方案——分片上传,vue大文件上传解决方案,vue大文件上传解决方案10G,vue大文件上传解决方案50G,vue大文件上传解决方案100G,html5如何实现大文件断点续传、秒传,
java如何实现大文件断点续传、秒传,SpringBoot如何实现大文件断点续传、秒传,SpringMVC如何实现大文件断点续传、秒传,SpringCloud如何实现大文件断点续传、秒传,
webuploader如何实现大文件断点续传、秒传,百度webuploader如何实现大文件断点续传、秒传,html5实现大文件断点续传、秒传,vue如何实现大文件断点续传、秒传,前端如何实现大文件断点续传、秒传,JavaScript如何实现大文件断点续传、秒传,
html5大文件断点续传、秒传解决方案,html5大文件断点续传、加密上传解决方案,html5大文件断点续传、加密存储解决方案,html5大文件断点续传分片解决方案,html5大文件断点续传分块解决方案,html5大文件断点续传分割解决方案,html5大文件断点续传切割解决方案,
后端我们公司项目组选的是JAVA,因为公司有自己的产品,所以直接使用就行了,针对客户需求这块是进行扩展。
客户这边实际上要传的文件单个大小就有50G左右,所以需要支持断点续传和分片上传,并且要支持多线程上传,能够充分利用带宽资源。
之前在网上找过相关的资料,论坛里面也有网友交流过,还加过很多QQ群和微信群,但是结果都不太令人满意。
技术选型的话用的是jquery,也是一个企业内网系统,用的是之前公司的框架,只是进行功能扩展
分片网上讨论的很多,基本上全部都是用的HTML5的API,这个方案也不是不能用,但是在IE下面就不行了,兼容性差了点,并且也不能进行扩展,不能进行二次开发,限制性非常大,我们技术同事提的要求是需要提供产品完整源代码,
网上的文章全部都没有提供文件夹上传和续传的代码,也没有提供数据库操作的代码,
另外这块我们是要求必须提供产品完整源代码,因为后面我们需要自已来维护,同时是要求能够自主可控的
研发部门的同事调研过百度的webuploader这个组件,发现他实际上就是对Flash和HTML5进行了一个封装,本质还是调的HTML5的API,之前在项目中也用过,尝试过,但是最终都不太满意,一个是兼容性非常差,说的是兼容IE,但是在IE用的是Flash,在很多用户的电脑上用不了,卡顿崩溃发生的太频繁,文件上传的数量一多比如几千个,前端页面就开始卡了,用户体验非常差。这些问题研发部的同事都向百度反应过,但是百度webuploader那边一直没人回,基本上没人管,领导说要求付费提供技术支持,那边也是没人回,联系不上他们任何人。
webuploader这边连个开发人员都联系不到,这个是怎么回事?
用户上传的文件比较大,有20G左右,直接用HTML传的话容易失败,服务器也容易出错,需要分片,分块,分割上传。也就是将一个大的文件分成若干个小文件块来上传,另外就是需要实现秒传功能和防重复功能,秒传就是用户如果上传过这个文件,那么直接在数据库中查找记录就行了,不用再上传一次,节省时间,实现的思路是对文件做MD5计算,将MD5值保存到数据库,算法可以用MD5,或者CRC,或者SHA1,这个随便哪个算法都行。
切片的话还有一点就是在服务器上合并,一个文件的所有分片数据上传完后需要在服务器端进行合并操作。
视频教程:https://www.ixigua.com/7227314770696012322
导入项目:
导入到Eclipse:http://www.ncmem.com/doc/view.aspx?id=9da9c7c2b91b40b7b09768eeb282e647
导入到IDEA:http://www.ncmem.com/doc/view.aspx?id=9fee385dfc0742448b56679420f22162
springboot统一配置:http://www.ncmem.com/doc/view.aspx?id=7768eec9284b48e3abe08f032f554ea2
下载示例:
https://gitee.com/xproer/up6-jsp-eclipse/tree/6.5.40/
工程
NOSQL
NOSQL示例不需要任何配置,可以直接访问测试
创建数据表
选择对应的数据表脚本,这里以SQL为例
修改数据库连接信息
访问页面进行测试
文件存储路径
up6/upload/年/月/日/guid/filename
相关问题:
1.javax.servlet.http.HttpServlet错误
2.项目无法发布到tomcat
3.md5计算完毕后卡住
4.服务器找不到config.json文件
相关参考:
文件保存位置
源码工程文档:https://drive.weixin.qq.com/s?k=ACoAYgezAAw1dWofra
源码报价单:https://drive.weixin.qq.com/s?k=ACoAYgezAAwoiul8gl
OEM版报价单:https://drive.weixin.qq.com/s?k=ACoAYgezAAwuzp4W0a
产品源代码:https://drive.weixin.qq.com/s?k=ACoAYgezAAwbdKCskc
授权生成器:https://drive.weixin.qq.com/s?k=ACoAYgezAAwTIcFph1
*请认真填写需求信息,我们会在24小时内与您取得联系。