Spring项目-抽奖系统(实操项目)(ONE)
^__^
(oo)\______
(__)\ )\/\
||----w |
|| ||
一:前言:
随着互联网技术的快速发展,线上营销活动已成为企业吸引用户、增强品牌影响力的重要手段。抽奖系统作为一种高效的用户互动工具,广泛应用于电商促销、节日活动、用户拉新等场景。然而,一个高并发、高可用且安全可靠的抽奖系统,不仅需要满足基本的随机抽奖功能,还需解决库存管理、概率控制、防刷机制、分布式事务等一系列技术挑战。
本项目基于Spring Boot框架,结合Spring生态的核心技术(如Spring MVC等),设计并实现了一个轻量级、模块化的抽奖系统。系统以可扩展性和高并发处理能力为核心目标,通过分层架构设计,将用户管理、抽奖规则配置、奖品库存管理、抽奖逻辑执行及结果记录等功能模块解耦,同时引入Redis实现分布式缓存与库存预扣减,利用MySQL的事务特性保障数据一致性,支持灵活的中奖策略配置。
1.1项目中涉及到的技术栈:
1. Spring Framework
Spring Boot:用于快速搭建和配置项目,简化Spring应用的开发。
Spring MVC:处理Web请求和响应,实现RESTful API。
Spring Transaction Management:管理事务,确保数据一致性。
MyBatis:是一个基于Java的持久层框架,主要用于管理数据库操作。
2. 数据库
MySQL:关系型数据库,用于存储用户、奖品、抽奖记录等数据。
Redis:作为缓存,存储抽奖活动的临时数据,如奖品库存、用户抽奖次数等。
3. 消息队列
RabbitMQ:用于异步处理抽奖请求,解耦系统模块,提升系统性能和可靠性。
4. 缓存
Redis:用于缓存热门数据,如奖品信息、用户抽奖记录等,减少数据库压力。
5. 安全
JWT:用于实现无状态的身份验证和授权机制。
6. 日志
Logback/Log4j2:用于记录系统日志,便于排查问题。
7. 部署
Ubantu:以桌面应用为主的 Linux 发行版操作系统。
8. 其他
Lombok:简化Java代码,减少样板代码。
Hutool:用于数据校验,生成随机数。
Spring AOP:用于实现切面编程,如日志记录、事务管理等。
1.2项目结构:
Controller层:处理HTTP请求,调用Service层。
Service层:实现业务逻辑,调用DAO层。
DAO层:与数据库交互,进行数据持久化操作。
DO层:定义数据模型,与数据库表对应。
DTO层:数据传输对象,用于前后端数据交互。
COMMON层:工具类,提供通用功能。
1.3关键功能:
用户管理:用户注册、登录、权限管理。
抽奖活动管理:创建、修改、删除抽奖活动。
抽奖逻辑:实现抽奖算法,确保公平性和随机性。
奖品管理:奖品库存管理、奖品发放。
抽奖记录:记录用户抽奖结果,提供查询功能。
项目gitee链接:lottery-system-plus/lottery-system-plus · Folio/JavaEE-Study - 码云 - 开源中国
二:系统设计:
2.1:系统架构:
前端 :使⽤J avaScript(JS) 管理各界⾯的动态性,使⽤ AJAX技术 从后端API获取数据。后端 :采⽤ Spring Boot3 构建后端应⽤,实现业务逻辑。数据库 :使⽤ MySQL 作为主数据库,存储⽤⼾数据和活动信息。缓存 :使⽤ Redis 作为缓存层,减少数据库访问次数。消息队列 :使⽤ RabbitMQ 处理异步任务,如处理抽奖⾏为。⽇志与安全 :使⽤ JWT 进⾏⽤⼾认证,使⽤ SLF4J+logback 完成⽇志。
项⽬环境编程语⾔:Java(后端),JavaScript(前端)。开发⼯具包:JDK 17 。后端框架:Spring Boot3。数据库:MySQL。缓存:Redis。消息队列:RabbitMQ。⽇志:logback。安全:JWT + 加密。
2.2:业务模块:
⼈员业务模块:管理员 注册、登录,及普通⽤⼾的创建 。活动业务模块:活动管理及活动状态管理。奖品业务模块:奖品管理与奖品的分配。还包括奖品图的上传。通知业务模块: 发送短信、邮件等业务 ,例如验证码发送,中奖通知。抽奖业务模块:完成抽奖动作,以及抽奖后的结果展⽰。
2.3:数据库设计:
⽤⼾表:存储⽤⼾信息,如⽤⼾名、密码、邮箱等。活动表:存储活动信息,如活动名称、描述、活动状态等。奖品表:存储奖品信息,如奖品名称、奖品图等。活动奖品关联表:存储⼀个活动下关联了哪些奖品。活动⽤⼾关联表:存储⼀个活动下设置的参与⼈员。中奖记录表:存储⼀个活动的中奖名单,如活动id,奖品id,中奖者id等
2.3.1:建表:
使⽤ source 命令导⼊ .sql ⽂件
SET NAMES utf8mb4;SET FOREIGN_KEY_CHECKS = 0;DROP DATABASE IF EXISTS `lottery_system`;CREATE DATABASE `lottery_system` CHARACTER SET utf8mb4 COLLATEutf8mb4_general_ci;USE `lottery_system`;-- ------------------------------ Table structure for activity-- ----------------------------DROP TABLE IF EXISTS `activity`;CREATE TABLE `activity` (`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT ' 主键 ',`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ' 创建时间 ',`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATECURRENT_TIMESTAMP COMMENT ' 更新时间 ',`activity_name` varchar(255) CHARACTER SET utf8mb3 COLLATEutf8mb3_general_ci NOT NULL COMMENT ' 活动名称 ',`description` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNOT NULL COMMENT ' 活动描述 ',`status` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOTNULL COMMENT ' 活动状态 ',PRIMARY KEY (`id`) USING BTREE,UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 24 CHARACTER SET = utf8mb3 COLLATE =utf8mb3_general_ci ROW_FORMAT = DYNAMIC;-- ------------------------------ Table structure for activity_prize-- ----------------------------DROP TABLE IF EXISTS `activity_prize`;CREATE TABLE `activity_prize` (`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT ' 主键 ',`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ' 创建时间 ',`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATECURRENT_TIMESTAMP COMMENT ' 更新时间 ',`activity_id` bigint NOT NULL COMMENT ' 活动 id',`prize_id` bigint NOT NULL COMMENT ' 活动关联的奖品 id',`prize_amount` bigint NOT NULL DEFAULT 1 COMMENT ' 关联奖品的数量 ',`prize_tiers` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNOT NULL COMMENT ' 奖品等级 ',`status` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOTNULL COMMENT ' 活动奖品状态 ',PRIMARY KEY (`id`) USING BTREE,UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE,UNIQUE INDEX `uk_a_p_id`(`activity_id` ASC, `prize_id` ASC) USING BTREE,INDEX `idx_activity_id`(`activity_id` ASC) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 32 CHARACTER SET = utf8mb3 COLLATE =utf8mb3_general_ci ROW_FORMAT = DYNAMIC;-- ------------------------------ Table structure for activity_user-- ----------------------------DROP TABLE IF EXISTS `activity_user`;CREATE TABLE `activity_user` (`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT ' 主键 ',`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ' 创建时间 ',`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATECURRENT_TIMESTAMP COMMENT ' 更新时间 ',`activity_id` bigint NOT NULL COMMENT ' 活动时间 ',`user_id` bigint NOT NULL COMMENT ' 圈选的⽤⼾ id',`user_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNOT NULL COMMENT ' ⽤⼾名 ',`status` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOTNULL COMMENT ' ⽤⼾状态 ',PRIMARY KEY (`id`) USING BTREE,UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE,UNIQUE INDEX `uk_a_u_id`(`activity_id` ASC, `user_id` ASC) USING BTREE,INDEX `idx_activity_id`(`activity_id` ASC) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb3 COLLATE =utf8mb3_general_ci ROW_FORMAT = DYNAMIC;-- ------------------------------ Table structure for prize-- ----------------------------DROP TABLE IF EXISTS `prize`;CREATE TABLE `prize` (`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT ' 主键 ',`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ' 创建时间 ',`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATECURRENT_TIMESTAMP COMMENT ' 更新时间 ',`name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOTNULL COMMENT ' 奖品名称 ',`description` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNULL DEFAULT NULL COMMENT ' 奖品描述 ',`price` decimal(10, 2) NOT NULL COMMENT ' 奖品价值 ',`image_url` varchar(2048) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNULL DEFAULT NULL COMMENT ' 奖品展⽰图 ',PRIMARY KEY (`id`) USING BTREE,UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 18 CHARACTER SET = utf8mb3 COLLATE =utf8mb3_general_ci ROW_FORMAT = DYNAMIC;-- ------------------------------ Table structure for user-- ----------------------------DROP TABLE IF EXISTS `user`;CREATE TABLE `user` (`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT ' 主键 ',`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ' 创建时间 ',`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATECURRENT_TIMESTAMP COMMENT ' 更新时间 ',`user_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNOT NULL COMMENT ' ⽤⼾姓名 ',`email` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOTNULL COMMENT ' 邮箱 ',`phone_number` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNOT NULL COMMENT ' ⼿机号 ',`password` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNULL DEFAULT NULL COMMENT ' 登录密码 ',`identity` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOTNULL COMMENT ' ⽤⼾⾝份 ',PRIMARY KEY (`id`) USING BTREE,UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE,UNIQUE INDEX `uk_email`(`email`(30) ASC) USING BTREE,UNIQUE INDEX `uk_phone_number`(`phone_number`(11) ASC) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 39 CHARACTER SET = utf8mb3 COLLATE =utf8mb3_general_ci ROW_FORMAT = DYNAMIC;-- ------------------------------ Table structure for winning_record-- ----------------------------DROP TABLE IF EXISTS `winning_record`;CREATE TABLE `winning_record` (`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT ' 主键 ',`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ' 创建时间 ',`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATECURRENT_TIMESTAMP COMMENT ' 更新时间 ',`activity_id` bigint NOT NULL COMMENT ' 活动 id',`activity_name` varchar(255) CHARACTER SET utf8mb3 COLLATEutf8mb3_general_ci NOT NULL COMMENT ' 活动名称 ',`prize_id` bigint NOT NULL COMMENT ' 奖品 id',`prize_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNOT NULL COMMENT ' 奖品名称 ',`prize_tier` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNOT NULL COMMENT ' 奖品等级 ',`winner_id` bigint NOT NULL COMMENT ' 中奖⼈ id',`winner_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNOT NULL COMMENT ' 中奖⼈姓名 ',`winner_email` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ciNOT NULL COMMENT ' 中奖⼈邮箱 ',`winner_phone_number` varchar(255) CHARACTER SET utf8mb3 COLLATEutf8mb3_general_ci NOT NULL COMMENT ' 中奖⼈电话 ',`winning_time` datetime NOT NULL COMMENT ' 中奖时间 ',PRIMARY KEY (`id`) USING BTREE,UNIQUE INDEX `uk_id`(`id` ASC) USING BTREE,UNIQUE INDEX `uk_w_a_p_id`(`winner_id` ASC, `activity_id` ASC, `prize_id`ASC) USING BTREE,INDEX `idx_activity_id`(`activity_id` ASC) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 69 CHARACTER SET = utf8mb3 COLLATE =utf8mb3_general_ci ROW_FORMAT = DYNAMIC;SET FOREIGN_KEY_CHECKS = 1;
数据库表ER图:

确定数据库创建完成:

(这里推荐大家可以使用MySql的可视化工具,可以使得开发更加快捷,例如:Navicat)
2.4:安全设计:
⽤⼾登录⾝份验证 :使⽤ JWT 进⾏⽤⼾⾝份验证。需强制⽤⼾在某些⻚⾯必须进⾏登录操作。加密 :敏感信息数据加密。例如⼿机号、⽤⼾密码等敏感数据落库需要加密。
三:项目启动:
使用IDEA开发项目:
3.1:代码结构设计:
代码结构设计参考《阿⾥巴巴Java开发⼿册》-- 第六章 ⼯程结构:
总结:
本项目大致分为4层(controller、service、dao、common),Manager层暂时不需要,加入一个Common层,代替Manager的一些功能,将所有的错误信息、异常处理放在该层以及一些通用的工具等。
其中阿里巴巴开发手册还有一个分层参考:
3. 【参考】分层领域模型规约:DO ( Data Object ) :与数据库表结构一一对应,通过 DAO 层向上传输数据源对象。DTO ( Data Transfer Object ) :数据传输对象, Service 或 Manager 向外传输的对象。BO ( Business Object ) :业务对象。由 Service 层输出的封装业务逻辑的对象。AO ( Application Object ) :应用对象。在 Web 层与 Service 层之间抽象的复用对象模型,极为贴近展示层,复用度不高。VO ( View Object ) :显示层对象,通常是 Web 向模板渲染引擎层传输的对象。Query :数据查询对象,各层接收上层的查询请求。注意超过 2 个参数的查询封装,禁止使用 Map 类来传输。
3.2: 功能模块设计:
3.2.1:错误码:
为了使得项目中出现的错误更加可控,这里引入错误码和错误信息,当遇到异常或者错误时,能够及时查明错误信息来源,方便解决。
构造错误码格式(骨架):
其中包含错误码和错误码信息。
import lombok.AllArgsConstructor;
import lombok.Data;@Data
//带全参的构造函数
@AllArgsConstructor
public class ErrorCode {/*** 错误码*/private final Integer code;/*** 错误信息*/private final String message;}
(错误码+错误信息,这里使用lombok包,原因在于该包中提供了@Data注解,该注解可以直接帮我们实现set、get方法可以直接使用,
还写了一个全参的构造方法,因为当出现错误时,应该同时返回错误码和错误码信息)
构造全局错误码:
有了错误码的骨架,现在构建全局错误码,当遇到不可预知的错误的时候,全局错误码可以出来兜底,不至于最后将错误无视!!
public interface GlobalErrorCodeConstants{/*** 访问成功*/ErrorCode SUCCESS = new ErrorCode(200,"成功");/*** 系统异常*/ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500,"系统异常");/*** 功能未实现*/ErrorCode NOT_IMPLEMENTED = new ErrorCode(501,"功能未实现");/*** 错误的配置项*/ErrorCode ERROR_CONFIGURATION = new ErrorCode(502,"错误的配置项");/*** 未知错误*/ErrorCode UNKNOW = new ErrorCode(999,"未知错误");
}
定义业务错误码----controller层模板:
public interface ControllerErrorCodeConstants {//---------人员模块错误码----------//---------活动模块错误码----------//---------奖品模块错误码----------//---------抽奖模块错误码----------}
定义业务错误码----service层模板:
public interface ServiceErrorCodeConstatns {//---------人员模块错误码----------//---------活动模块错误码----------//---------奖品模块错误码----------//---------抽奖模块错误码----------
}
后续写具体业务的时候遇到什么错误,就往里面去填充!
3.2.2:自定义异常类:
在实现业务出现异常时,需要我们进行控制,需要及时捕捉异常,尽量不要向上抛给JVM去处理。
同样也定义异常错误码和异常信息。
@Data
@EqualsAndHashCode(callSuper = true)
public class ControllerException extends RuntimeException{/*** 业务错误码* @see com.example.lotterysystemplus.common.errorcode.ControllerErrorCodeConstants*/private Integer code;/*** 错误提示*/private String message;/*** 空构造方法,避免反序列化问题*/public ControllerException() {}public ControllerException(ErrorCode errorCode) {this.code = errorCode.getCode();this.message = errorCode.getMessage();}public ControllerException(Integer code, String message) {this.code = code;this.message = message;}
}
有以下几点需要解释一下:
1.为什么要写两个不同的构造方法呢?
(1).当想要抛出异常时,该异常中肯定要携带错误原因,可以直接使用我们之前构造好的ErroeCode类,该类中包含了错误码和错误码信息。
2.为什么要写一个空的构造方法呢?
(1).当需要进行反序列化操作时,需要无参构造方法作为容器!
详情可浏览:
3.3: 统一返回格式:
3.3.1:创建统一序列化对象
当控制层接收到前端传过来的参数后,处理完成后返回给前端的数据的格式应该需要统一。
这里采用JSON格式返回。
在JSON格式返回之前,需要统一序列化对象
因此需要写一个类,而且可以自动将返回结果转化为JSON格式:
/*** 统一序列化对象* @param <T>*/
@Data
public class CommonResult<T> implements Serializable {//填写返回时数据类型/*** 错误码*/private Integer code;/*** 错误提示*/private String msg;/*** 返回数据*/private T data;/*** /*返回时调用该统一方法*/public static <T> CommonResult<T> error(CommonResult<?> result) {return error(result.getCode(), result.getMsg());}public static <T> CommonResult<T> error(ErrorCode errorCode) {return error(errorCode.getCode(), errorCode.getMessage());}/*** 将失败返回结果转化为CommonResult类型传出*/public static <T> CommonResult<T> error(Integer code, String message) {Assert.isTrue(!GlobalErrorCodeConstants.SUCCESS.getCode().equals(code), "code 必须是错误的!");CommonResult<T> result = new CommonResult<>();result.setCode(code);result.setMsg(message);return result;}/*** 将成功返回结果转化为CommonResult类型传出*/public static <T> CommonResult<T> succcess(T data) {CommonResult<T> commonResult = new CommonResult<>();commonResult.setCode(GlobalErrorCodeConstants.SUCCESS.getCode());commonResult.setMsg("");commonResult.setData(data);return commonResult;}/*** 判断传入的状态码是否是成功状态*/public static Boolean isSuccess(Integer code) {return Objects.equals(code,GlobalErrorCodeConstants.SUCCESS.getCode());}
}
3.3.2:创建序列化与反序列化工具类:
主要用到的时Jackson包中的工具类——ObjectMapper类,ObjectMapper类提供了writeValueAsString方法(序列化方法)与readVaule反序列化方法。
但是在项目集成开发时,尽量不直接使用,进行封装作为公共接口使用。
以下是对ObjectMapper类进行的封装,其中涉及到了单例模式(静态方法单例),对一些异常进行控制捕获!并且用到了lambda表达式进行简写调用!
package com.example.lotterysystemplus.common.utils;import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.json.JsonParseException;
import org.springframework.util.ReflectionUtils;import java.util.List;
import java.util.concurrent.Callable;public class JacksonUtil {private JacksonUtil() {}/*** 单例*/private final static ObjectMapper OBJECT_MAPPER;static{OBJECT_MAPPER = new ObjectMapper();}private static ObjectMapper getObjectMapper() {return OBJECT_MAPPER;}public static <T> T tryParse(Callable<T> parser) {return tryParse(parser, JacksonException.class);}public static <T> T tryParse(Callable<T> parser, Class<? extends Exception> check) {try {return parser.call();} catch (Exception var4) {if (check.isAssignableFrom(var4.getClass())) {throw new JsonParseException(var4);} else {throw new IllegalStateException(var4);}}}public static String writeValueAsString(Object object) {return JacksonUtil.tryParse(()->{return JacksonUtil.getObjectMapper().writeValueAsString(object);});}/*** 反序列化object方法* @param content* @param valueType* @return* @param <T>*/public static <T> T readValue(String content,Class<T> valueType) {return JacksonUtil.tryParse(()->{return JacksonUtil.getObjectMapper().readValue(content,valueType);});}/*** 反序列化List方法* @param content* @param paramClasses* @return* @param <T>*/public static <T> T readListValue(String content,Class<?> paramClasses) {JavaType javaType = JacksonUtil.getObjectMapper().getTypeFactory().constructParametricType(List.class, paramClasses);return JacksonUtil.tryParse(()->{return JacksonUtil.getObjectMapper().readValue(content,javaType);});}}
3.4:日志处理:
项目中需要有日志监控,当项目在运行过程中出现异常时,我们希望可以及时报警,并且希望报警的信息内容尽可能详细。
这里采用lombok包下面的@Slf4j注解进行日志的相关处理,这比起之前的日志打印方便许多。当需要打日志时,只需要在该类上方添加@Slf4j注解(前提是添加了lombok坐标),用log.方式进行日志说明。
例如:

四:用户模块:
4.1:注册:
用户如果想要注册一个新用户时,需要进行以下内容的填写:
1、用户姓名
2、用户邮箱
3、用户手机号
4、用户密码
UI界面如下:

此时有以下几个问题:
1.密码可见性问题(加密问题):
当用户注册完毕后,相关信息肯定是要放入数据库进行存储,但是用户注册时的密码属于用户隐私,就算是管理员我们也不应该窃取用户密码,所以在存入数据库之前涉及到加密问题。
2.手机号可见性问题:
手机号作为用户的信息之一也是非常重要的,但是重要程度肯定没有密码那么高,但是为了防止有人盗窃数据库,为了防止有人给你打"骚扰电话"!!因此手机号应该也被加密存储起来。
3.校验问题:
当用户把所有信息填写完毕之后,我们需要对所填写的信息信息及时校验,尤其是格式、长度等的校验,防止有的用户"乱填"!
针对以上出现的相关问题,做出提前"防御":
4.1.1:加密:
首先我们要清楚,我们需要对两个信息进行加密,分别是手机号和密码。
但是加密的方式很多,究竟采用哪种加密合适呢?
以下有几种常见的加密方式:
对称加密:⽐如3DES、AES等算法,使⽤这种⽅式加密是可以通过解密来还原出原始密码的,当然前提条件是需要获取到密钥。密钥很可能也会泄露,当然可以将⼀般数据和密钥分开存储、分开管理,但要完全保护好密钥也是⼀件⾮常复杂的事情,所以这种⽅式并不是很好的⽅式。哈希:加密不可逆, 同一个字符加密所得到的密文是相同的 。可能会涉及到彩虹表攻击加盐哈希:加盐哈希是⽬前业界最常⻅的做法。⽤⼾注册时,给他随机⽣成⼀段字符串,这段字符串就是盐(Salt)把⽤⼾注册输⼊的密码和盐拼接在⼀起,叫做加盐密码对加盐密码进⾏哈希,并把结果和盐都储存起来
上述加密方式的不同:
对称加密:加密后的密文是可逆的,通过"密钥"可以还原出初始值!
哈希加密:加密后的密文是不可逆的,拿不到初始值!
如果考虑安全性,哈希加密相较可能更加安全,因为加密之后,就连管理员也很难直到初始值。
但是需要考虑业务场景:
密码加密:我们管理员不需要直到用户的密码,只需要在用户注册完成时,将加密后的密文存放在数据库中,如果进行登陆操作时,只需要将登录密码加密之后与数据库中的密文进行比较即可。
手机号加密:由于此次登录页面涉及到可以用手机验证码登录,因此我们给用户发送登陆短信时,是需要用到用户的手机号的,所以采用对称加密的方式更加稳妥一点。
加密工具:
引入相关依赖:
<dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.25</version>
</dependency>
建议大家可以到Hutool的官网有相关加密案例,还有更多有用的方法可以学习!
加密的相关原理和方法掌握以后,我们后续再实现对手机号和密码的"自动加密操作"!!
4.2:用户注册:
用户注册时序图:

该时序图中清楚反映出用户注册时的业务逻辑。
当然再我们编写代码时候,是需要按照阿里巴巴开发手册进行每一步的完成:

前后端约定:
[ 请求 ] /register POST{"name":"张三 ","mail":"451@qq.com","phoneNumber":"13188888888","password":"123456789","identity":"ADMIN"}[ 响应 ]{"code": 200,"data": {"userId": 22},"msg": ""}
4.2.1:Controller层设计:
Controller层是接收和发送数据的进出口,需要对发送的数据进行一定的封装(序列化为JSON格式),对接收到的信息进行反序列化操作,打印日志时也需要序列化操作,让日志看起来更加简洁明了:
@Slf4j
@RestController
public class UserController {@Autowiredprivate UserService userService;/*** @UserRegisterResult 返回响应的内容进行序列化操作* @param param 以JSON格式进行接收* @Validated 该注解用于检验接收到的数据是否符合规定* @return*/@RequestMapping("/register")public CommonResult<UserRegisterResult> register(@Validated @RequestBody UserRegisterParam param) {log.info("register UserRegisterParam:{}",JacksonUtil.writeValueAsString(param));UserRegisterDTO userRegisterDTO = userService.register();return CommonResult.succcess(covertToRegisterResult(userRegisterDTO));}/*** 对Service层传下来的数据进行最后的处理并返回* @param userRegisterDTO* @return*/private UserRegisterResult covertToRegisterResult(UserRegisterDTO userRegisterDTO) {if(userRegisterDTO == null) {throw new ControllerException(ControllerErrorCodeConstants.REGISTER_ERROR);}UserRegisterResult result = new UserRegisterResult();result.setUserId(userRegisterDTO.getUserId());return result;}
}
(1):UserRegisterResult设计:
该类负责最后要给前端返回的结果控制:
@Data
public class UserRegisterResult implements Serializable {/*** 用户id*/private Integer userId;
}
(2):UserRegisterParam设计:
该类负责接收前端传过来的结果,所以要与前端的变量名保持一致:
@Data
public class UserRegisterParam implements Serializable {/*** 用户姓名*/@NotBlank(message = "姓名不能为空!")private String name;/*** 用户邮箱*/@NotBlank(message = "邮箱不能为空!")private String mail;/*** 用户电话号码*/@NotBlank(message = "电话号码不能为空!")private String phoneNumber;/*** 用户密码* 对于管理员来说密码不能为空* 对于普通用户来书不用设置密码*/private String password;/*** 用户身份* 身份设计为枚举类*/private String identity;
}
其中要使用@NotBlank等对参数进行校验,需要添加如下的依赖:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
(3):UserRegisterDTO:
该类负责Service层向Controller层传输的结果接收:
@Data
public class UserRegisterDTO implements Serializable {/*** 用户id*/private Integer userId;
}
(4):UserIdentityEnum枚举类:
该类负责存放identity的全部类型:
@AllArgsConstructor
@Getter
public enum UserIdentityEnum {ADMIN("管理员"),NORMAL("普通用户");private final String name;/*** 自动判断身份信息并返回*/public static UserIdentityEnum forname(String name) {for(UserIdentityEnum userIdentityEnum:UserIdentityEnum.values()) {if(name.equals(userIdentityEnum.name)) {return userIdentityEnum;}}return null;}
}
4.2.2:Service层设计:
Service层涉及到业务的处理,与数据库打交道。
@Service
public interface UserService {/*** 完成具体注册业务的方法* @return*/public UserRegisterDTO register();
}
(1):ServiceImpl层设计:
该层中的Service相关类是实现UserService接口,具体实现业务逻辑的类:
/*** UserService类中方法的具体实现*/
@Slf4j
@Service
public class UserServiceImpl implements UserService {@Autowiredprivate UserMapper userMapper;/*** 完成注册具体业务* @param param* @return*/@Overridepublic UserRegisterDTO register(UserRegisterParam param) {//校验参数//Controller进行了简单的校验,Service层需要进行更加具体的校验工作checkRegisterInfo(param);//入库UserDO userDO = new UserDO();userDO.setUserName(param.getName());userDO.setEmail(param.getEmail());userDO.setPhoneNumber(new Encrypt(param.getPhoneNumber()));if(StringUtils.hasText(param.getPassword())) {//采用md5加密userDO.setPassword(DigestUtil.md5Hex(param.getPassword()));}userDO.setIdentity(param.getIdentity());//返回结果UserRegisterDTO userRegisterDTO = new UserRegisterDTO();userRegisterDTO.setUserId(userDO.getUserId());return userRegisterDTO;}/*** 校验人员的身份信息是否满足注册要求* @request request*/private void checkRegisterInfo(UserRegisterParam request) {if(request == null) {throw new ServiceException(ServiceErrorCodeConstatns.REGISTER_INFO_IS_EMPTY);}/*** 邮箱\手机号校验运用正则表达式(Regex)校验* @see com.example.lotterysystemplus.common.utils.RegexUtil*///邮箱格式校验if(!RegexUtil.checkMail(request.getEmail())) {throw new ServiceException(ServiceErrorCodeConstatns.REGISTER_EMAIL_ERROR);}//手机号格式校验if(!RegexUtil.checkMobile(request.getPhoneNumber())) {throw new ServiceException(ServiceErrorCodeConstatns.REGISTER_PHONENUMBER_ERROR);}//检查身份信息if(null == UserIdentityEnum.forname(request.getIdentity())) {throw new ServiceException(ServiceErrorCodeConstatns.REGISTER_IDENTITY_ERROR);}//管理员必须设置密码if(UserIdentityEnum.ADMIN.getName().equals(request.getIdentity())&& !StringUtils.hasText(request.getPassword())) {throw new ServiceException(ServiceErrorCodeConstatns.ADMIN_PASSWORD_IS_EMPTY);}//密码格式校验,最少6位if(!RegexUtil.checkPassword(request.getPassword())) {throw new ServiceException(ServiceErrorCodeConstatns.REGISTER_PASSWORD_ERROR);}//检查邮箱是否被使用if(checkMailUsed(request.getEmail())) {throw new ServiceException(ServiceErrorCodeConstatns.REGISTER_MAIL_IS_USED);}//检查手机号是否被使用if(checkPhoneNumberUsed(request.getPhoneNumber())) {throw new ServiceException(ServiceErrorCodeConstatns.REGISTER_PHONENUMBER_IS_USED);}}/*** 检查手机号是否被使用* @param phoneNumber* @return*/private boolean checkPhoneNumberUsed(String phoneNumber) {//检查是否为空if(!StringUtils.hasText(phoneNumber)) {throw new ServiceException(ServiceErrorCodeConstatns.REGISTER_PHONENUMBER_IS_EMPTY);}//从数据库中查重int countUser = userMapper.selectByPhoneNumber(new Encrypt(phoneNumber));return countUser>0;}/*** 检查邮箱是否被使用* @param email* @return*/private boolean checkMailUsed(String email) {//检查是否为空if(!StringUtils.hasText(email)) {throw new ServiceException(ServiceErrorCodeConstatns.REGISTER_MAIL_IS_USED);}//从数据库中查重int countUser = userMapper.selectByEmail(email);return countUser>0;}
}
(2):dataobject层设计:
该层放的类,都是直接与数据库交互的类,里面的成员变量要与数据库的统一:
BaseDO:
通用类:数据库中常用字段
@Data
public class BaseDO {/*** 用户id*/private Integer userId;/*** 创建时间*/private Date gmtCreate;/*** 更新时间*/private Date gmtModified;
}
UserDO:本次注册时与数据库接触的成员变量
@Data
public class UserDO extends BaseDO{/*** 用户姓名*/private String userName;/*** 用户邮箱*/private String email;/*** 用户电话号码*/private Encrypt phoneNumber;/*** 用户密码* 对于管理员来说密码不能为空* 对于普通用户来书不用设置密码*/private String password;/*** 用户身份* 身份设计为枚举类*/private String identity;
}
此处需要说明两点:
1.数据库中存放的用户手机号和密码都必须是加密过以后的,所以在ServiceImpl类中实现具体业务方法时,一定要将用户的密码和手机号进行加密处理。
2.为了方便起见,手机号时是采用对称加密的方式,放入数据库时需要加密,从数据库取出时需要解密,因此可以写一个自动化加解密的方法:EncryptTypeHandler方法:
原理如下图:

/*** Jdbc的数据类型* 需要进行自动化加解密的类*/
@MappedJdbcTypes(JdbcType.VARCHAR)
@MappedTypes(Encrypt.class)
public class EncryptTypeHandler extends BaseTypeHandler<Encrypt> {private static final byte[] KEYS = "123456789ABCDEFG".getBytes(StandardCharsets.UTF_8);@Overridepublic void setNonNullParameter(PreparedStatement ps, int i, Encrypt parameter, JdbcType jdbcType) throws SQLException {if(parameter == null || parameter.getValue() == null) {ps.setString(i,null);return ;}AES aes = SecureUtil.aes(KEYS);String secret = aes.encryptHex(parameter.getValue());ps.setString(i,secret);}/*** 解密* @param rs* @param columnName* @return* @throws SQLException*/@Overridepublic Encrypt getNullableResult(ResultSet rs, String columnName) throws SQLException {return decrypt(rs.getString(columnName));}/*** 解密* @param rs* @param columnIndex* @return* @throws SQLException*/@Overridepublic Encrypt getNullableResult(ResultSet rs, int columnIndex) throws SQLException {return decrypt(rs.getString(columnIndex));}/*** 解密* @param cs 结果集* @param columnIndex 索引* @return* @throws SQLException*/@Overridepublic Encrypt getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {System.out.println("获取到加密内容"+cs.getString(columnIndex));return decrypt(cs.getString(columnIndex));}/*** 解密方法* @param str* @return*/private Encrypt decrypt(String str) {if (!StringUtils.hasText(str)) {return null;}return new Encrypt(SecureUtil.aes(KEYS).decryptStr(str));}}
4.3:dao层设计:
与数据库直接打交道的可以放在dao层处理:
4.3.1:Mapper层:
该层是写sql语句的一层,是直接与数据库对接的一层:
1.注册时插入接口
2.查询重复手机号接口
3.查询重复邮箱号接口
@Mapper
public interface UserMapper {//插入新的注册信息@Insert("insert into user (user_name, email, password, phone_number, `identity`)" +" values (#{userName}, #{email}, #{password}, #{phoneNumber}, #{identity})")Integer insert(UserDO userDO);//查询重复手机号@Select("select count(1) from user where phone_number = #{phoneNumber} ")Integer selectByPhoneNumber(@Param("phoneNumber") Encrypt phoneNumber);//查询重复邮箱@Select("select count(1) from user where email = #{email} ")Integer selectByEmail(@Param("email") String email);}
4.4:全局异常处理:
注册后端写完之后,我们还需要注意一点,当发生不可预知异常时,我们需要控制住,不能直接交给JVM去处理!
因此有了设计EncryptTypeHandler的思路之后,我们也可以写一个全局的异常处理,防止异常无法控制!
@RestControllerAdvice //可以捕获全局抛得异常
@Slf4j
public class GlobalExceptionHandler {@ExceptionHandler(value = ServiceException.class)public CommonResult<?> ServiceException(ServiceException e) {//打印错误日志log.error("ServiceException:{}",e);//构造返回结果return CommonResult.error(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(),e.getMessage());}@ExceptionHandler(value = ControllerException.class)public CommonResult<?> ControllerException(ServiceException e) {//打印错误日志log.error("ServiceException:{}",e);//构造返回结果return CommonResult.error(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(),e.getMessage());}@ExceptionHandler(value = Exception.class)public CommonResult<?> Exception(ServiceException e) {//打印错误日志log.error("服务异常",e);//构造返回结果return CommonResult.error(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(),e.getMessage());}}
注册的相关接口的实现到着也就接近尾声了,当然大伙棵以适当的添加更加有趣功能:例如还可以增加二维码验证等有趣的注册方式!
接下来需要完成登录、抽奖活动设计等相关功能,感兴趣的朋友,可以继续跟我一起探索:
^__^
(oo)\______
(__)\ )\/\
||----w |
|| ||
相关文章:
Spring项目-抽奖系统(实操项目)(ONE)
^__^ (oo)\______ (__)\ )\/\ ||----w | || || 一:前言: 随着互联网技术的快速发展,线上营销活动已成为企业吸引用户、…...
STM32-智能小车项目
项目框图 ST-link接线 实物图: 正面: 反面: 相关内容 使用L9110S电机模块 电机驱动模块L9110S详解 | 良许嵌入式 测速模块 语音模块SU-03T 网站:智能公元/AI产品零代码平台 一、让小车动起来 新建文件夹智能小车项目 在里面…...
Python:字符串常见操作
find(子字符串,开始位置下标,结束位置下标) 注意:开始位置和结束位置下标可以省略,表示在整个字符串中查找 stasdfghjkl print(st.find(a))#输出结果为0,表明a在第一个位置默认从零开始,找不到则返回-1 …...
Redis 哈希(Hash)
Redis 哈希(Hash) 概述 Redis 哈希(Hash)是一种特殊的键值对类型,它允许存储结构化的数据,例如一个对象或记录。每个哈希值可以包含多个字段,每个字段又可以存储一个字符串值。这使得Redis哈希非常适合用于存储对象的…...
Windows对比MacOS
Windows对比MacOS 文章目录 Windows对比MacOS1-环境变量1-Windows添加环境变量示例步骤 1:打开环境变量设置窗口步骤 2:添加系统环境变量 2-Mac 系统添加环境变量示例步骤 1:打开终端步骤 2:编辑环境变量配置文件步骤 3࿱…...
react 路由跳转的几种方式
在 React 中,路由跳转通常通过 react-router-dom(或类似的路由库)来实现。以下是几种常见的路由跳转方式: 1. 使用 <Link> 组件 <Link> 是最简单的路由跳转方式,它会生成一个 <a> 标签,…...
2.你有什么绝活儿?—Java能做什么?
1、Java的绝活儿:要问Java有什么绝活,我觉得它应该算是一位魔法师,会的绝活儿有很多,要说最能拿得出手的当属以下三个。 1.1 平台无关性:Java可以在任何地方施展魔法,无论是Windows、Linux还是Mac…...
2025年2月文章一览
2025年2月编程人总共更新了17篇文章: 1.2025年1月文章一览 2.《Operating System Concepts》阅读笔记:p2-p8 3.《Operating System Concepts》阅读笔记:p9-p12 4.《Operating System Concepts》阅读笔记:p13-p16 5.《Operati…...
C++ | 面向对象 | 类
👻类 👾语法格式 class className{Access specifiers: // 访问权限DataType variable; // 变量returnType functions() { } // 方法 };👾访问权限 class className {public:// 公有成员protected:// 受保护成员private:// 私有成员 }…...
leetcode:2164. 对奇偶下标分别排序(python3解法)
难度:简单 给你一个下标从 0 开始的整数数组 nums 。根据下述规则重排 nums 中的值: 按 非递增 顺序排列 nums 奇数下标 上的所有值。 举个例子,如果排序前 nums [4,1,2,3] ,对奇数下标的值排序后变为 [4,3,2,1] 。奇数下标 1 和…...
Visionpro cogToolBlockEditV2.Refresh()
在 C# 中使用 cogToolBlockEditV2.Refresh() 方法主要用于刷新 CogToolBlockEditV2 控件的显示状态,适用于动态更新界面或重新加载工具块(ToolBlock)的场景。以下是具体说明和典型应用场景。 基本作用 刷新控件显示:当修改了与 C…...
Apache Spark中的依赖关系与任务调度机制解析
Apache Spark中的依赖关系与任务调度机制解析 在Spark的分布式计算框架中,RDD(弹性分布式数据集)的依赖关系是理解任务调度、性能优化及容错机制的关键。宽依赖(Wide Dependency)与窄依赖(Narrow Dependency)作为两种核心依赖类型,直接影响Stage划分、Shuffle操作及容…...
网络基础III
目录 一、网络层 1.1IP协议 1.2网段划分(🔺) 1.3特殊的ip地址 1.4ip地址的数量限制 1.5私有ip和公网ip 1.6路由 二、数据链路层 2.1认识以太网 2.2以太网帧格式 2.3认识mac地址 2.4mac地址和ip地址 2.5认识MTU 2.6MTU对IP协议的…...
【SpringBoot】自动配置原理与自定义启动器
Spring Boot 自动配置原理与自定义启动器 目录标题 Spring Boot 自动配置原理与自定义启动器摘要1. 引言2. Spring Boot自动配置原理分析2.1 自动配置的核心流程2.2 核心注解与配置文件解析2.2.1 EnableAutoConfiguration2.2.2 spring.factories 文件 2.3 自动配置类剖析2.4 配…...
Element实现el-dialog弹框移动、全屏功能
1、在Vue项目中src/utils目录中创建dialog.js,用来定义draggable-dialog; import Vue from vue Vue.directive(draggable-dialog, { // 属性名称draggable-dialog,前面加v- 使用bind(el, binding, vnode) {const dialogHeaderEl el.querySe…...
Ubuntu 下 nginx-1.24.0 源码分析 - ngx_init_cycle 函数 - 详解(11)
详解(11) 初始化配置解析上下文 senv environ;ngx_memzero(&conf, sizeof(ngx_conf_t));/* STUB: init array ? */conf.args ngx_array_create(pool, 10, sizeof(ngx_str_t));if (conf.args NULL) {ngx_destroy_pool(pool);return NULL;}conf.te…...
千峰React:案例一
做这个案例捏 因为需要用到样式,所以创建一个样式文件: //29_实战.module.css .active{text-decoration:line-through } 然后创建jsx文件,修改main文件:导入Todos,写入Todos组件 import { StrictMode } from react …...
部署Joplin私有云服务器postgres版-docker compose
我曾经使用过一段时间 Joplin,官方版本是收费的,而我更倾向于将数据掌握在自己手中。因此,在多次权衡后,我决定自己搭建 Joplin 服务器并进行尝试。 个人搭建的版本与数据库直连,下面是使用 Docker Compose 配置数据库…...
rust学习笔记6-数组练习704. 二分查找
上次说到rust所有权看看它和其他语言比有什么优势,就以python为例 # Python3 def test():a [1, 3, -4, 7, 9]print(a[4])b a # 所有权没有发生转移del b[4]print(a[4]) # 由于b做了删除,导致a再度访问报数组越界if __name__ __main__:test() 运行结…...
Jsmoke-一款强大的js检测工具,浏览器部署即用,使用方便且高效
目录标题 Jsmoke 🚬🚬 by Yn8rt使用方式界面预览功能特性支持的敏感信息类型 Jsmoke 🚬🚬 by Yn8rt 该插件由 Yn8rt师傅 开发,插件可以理解为主动版的hae和apifinder,因为其中的大多数规则我都引用了&a…...
深入浅出Asp.Net Core MVC应用开发系列-AspNetCore中的日志记录
ASP.NET Core 是一个跨平台的开源框架,用于在 Windows、macOS 或 Linux 上生成基于云的新式 Web 应用。 ASP.NET Core 中的日志记录 .NET 通过 ILogger API 支持高性能结构化日志记录,以帮助监视应用程序行为和诊断问题。 可以通过配置不同的记录提供程…...
进程地址空间(比特课总结)
一、进程地址空间 1. 环境变量 1 )⽤户级环境变量与系统级环境变量 全局属性:环境变量具有全局属性,会被⼦进程继承。例如当bash启动⼦进程时,环 境变量会⾃动传递给⼦进程。 本地变量限制:本地变量只在当前进程(ba…...
shell脚本--常见案例
1、自动备份文件或目录 2、批量重命名文件 3、查找并删除指定名称的文件: 4、批量删除文件 5、查找并替换文件内容 6、批量创建文件 7、创建文件夹并移动文件 8、在文件夹中查找文件...
鸿蒙中用HarmonyOS SDK应用服务 HarmonyOS5开发一个医院挂号小程序
一、开发准备 环境搭建: 安装DevEco Studio 3.0或更高版本配置HarmonyOS SDK申请开发者账号 项目创建: File > New > Create Project > Application (选择"Empty Ability") 二、核心功能实现 1. 医院科室展示 /…...
Auto-Coder使用GPT-4o完成:在用TabPFN这个模型构建一个预测未来3天涨跌的分类任务
通过akshare库,获取股票数据,并生成TabPFN这个模型 可以识别、处理的格式,写一个完整的预处理示例,并构建一个预测未来 3 天股价涨跌的分类任务 用TabPFN这个模型构建一个预测未来 3 天股价涨跌的分类任务,进行预测并输…...
【算法训练营Day07】字符串part1
文章目录 反转字符串反转字符串II替换数字 反转字符串 题目链接:344. 反转字符串 双指针法,两个指针的元素直接调转即可 class Solution {public void reverseString(char[] s) {int head 0;int end s.length - 1;while(head < end) {char temp …...
如何为服务器生成TLS证书
TLS(Transport Layer Security)证书是确保网络通信安全的重要手段,它通过加密技术保护传输的数据不被窃听和篡改。在服务器上配置TLS证书,可以使用户通过HTTPS协议安全地访问您的网站。本文将详细介绍如何在服务器上生成一个TLS证…...
VTK如何让部分单位不可见
最近遇到一个需求,需要让一个vtkDataSet中的部分单元不可见,查阅了一些资料大概有以下几种方式 1.通过颜色映射表来进行,是最正规的做法 vtkNew<vtkLookupTable> lut; //值为0不显示,主要是最后一个参数,透明度…...
pikachu靶场通关笔记22-1 SQL注入05-1-insert注入(报错法)
目录 一、SQL注入 二、insert注入 三、报错型注入 四、updatexml函数 五、源码审计 六、insert渗透实战 1、渗透准备 2、获取数据库名database 3、获取表名table 4、获取列名column 5、获取字段 本系列为通过《pikachu靶场通关笔记》的SQL注入关卡(共10关࿰…...
基于Java Swing的电子通讯录设计与实现:附系统托盘功能代码详解
JAVASQL电子通讯录带系统托盘 一、系统概述 本电子通讯录系统采用Java Swing开发桌面应用,结合SQLite数据库实现联系人管理功能,并集成系统托盘功能提升用户体验。系统支持联系人的增删改查、分组管理、搜索过滤等功能,同时可以最小化到系统…...
