宠物乐园这个项目是为xxx公司开发o2o模式一个项目。它是以宠物为中心,提供宠物领养,寻主的基本功能,还提供了宠物服务,相关物品购买,宠物相关知识学习等功能一个综合性平台。它主要有组织机构,用户,服务,宠物,订单, 支付等模块。它是使用前后端分离开发模式,前端使用的是vue技术栈,后台使用的是springboot+ssm+docker。
共11人 周期:半年
项目经理:管人管项目 1
架构师: 负责项目架构+技术选型+疑难问题解决+培训 1
UI: 设计界面 1
H5: 前端开发工程师 2
后台: 后台开发工程师 4
测试: 测试人员 1
运维人员: 搭建开发公共环境,线上环境 1。
用户注册分为邮箱注册和手机号注册,由于逻辑都是一样的,只是激活方式不一样,所以这里只以手机号为例
手机号注册:
1)输入手机号
2)获取验证码并且输入
3)输入密码和确认密码
4)完成注册
手机发送短信验证码流程
(1) 1张用户信息表t_user
注意:
1.如果是手机号注册,用户的默认状态就是激活状态。如果是邮箱注册,需要给用户发送邮件,让用户登录邮件进行激活。目的是防止用户随便写一个不可用的邮箱注册账号。但是目前大多数新项目都不用了,都用手机号。
2.前台用户注册的密码不能是明文的形式存储在数据库,需要进行加密处理,防止用户信息泄露。
(2)1张登录信息表t_logininfo
表里面有一列栏位type,用于区分是后台工作人员登录还是前台用户登录. 因为我的设计就算你是管理员,你想消费,你也要注册用户,意味着员工表和用户表中分别有记录。 也就会造成logininfo有相同电话或用户名或email等两个记录,通过type进行区分。
(1)工具类StrUtils,生成随机字符串,作为验证码
public static String getComplexRandomString(int length) {
String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
Random random = new Random();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < length; i++) {
int number = random.nextInt(62);
sb.append(str.charAt(number));
}
return sb.toString();
}
(2)md5不可逆加密,用来加密用户登录密码
盐值salt:同一种加密算法,由于不同的盐值,加密出来就不一样。
1)整个系统使用同一个盐值,多个用户共用-定义一个常量
每个用户都有自己盐值,就算是相同的密码,两个用用户加密出来也不一样
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class MD5Utils {
/**
* 加密
* @param context
*/
public static String encrypByMd5(String context) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(context.getBytes());//update处理
byte [] encryContext = md.digest();//调用该方法完成计算
int i;
StringBuffer buf = new StringBuffer("");
for (int offset = 0; offset < encryContext.length; offset++) {//做相应的转化(十六进制)
i = encryContext[offset];
if (i < 0) i += 256;
if (i < 16) buf.append("0");
buf.append(Integer.toHexString(i));
}
return buf.toString();
} catch (NoSuchAlgorithmException e) {
// TODO Auto-generated catch block
e.printStackTrace();
return null;
}
}
(3)引入redis数据库
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
(4)短信发送接口
找三大运营,或者中间商,我们项目中使用网建短信通
链接: http://sms.webchinese.com.cn/Rates.shtml
导包,并准备发送短信的工具类
<dependency>
<groupId>commons-httpclient</groupId>
<artifactId>commons-httpclient</artifactId>
<version>3.1</version>
</dependency>
@Override
public AjaxResult sendSmsCode(Map<String, String> param) {
String phone = param.get("phone");
if(!StringUtils.hasLength(phone)) {
return AjaxResult.result().setMessage("请输入手机号");
}
User user = userMapper.loadByPhone(phone);
if(user != null){
return AjaxResult.result().setMessage("该手机号已被注册");
}
String key = "registerCode" + phone;
String value =(String)redisTemplate.opsForValue().get(key);
String code = "";
//如果value为null,说明是第一次或者过期了,则生成随机验证码,并且下面会存放到redis里面去
if(!StringUtils.hasLength(value)){
code = StrUtils.getComplexRandomString(4);
}else {
//如果value不为空,说明存在验证码,判断是否过了重发事件,还是用同一个验证码
String time = value.split(":")[1];
if(System.currentTimeMillis()-Long.parseLong(time) < 1*60*1000){
return AjaxResult.result().setMessage("请不要频繁发送");
}
code = value.split(":")[0];
}
//存放到redis,有效时间3分钟.设置value的格式为 验证码:存入时间
int expireTime = 3;
redisTemplate.opsForValue().set(key,code +":"+ System.currentTimeMillis(),
expireTime,TimeUnit.MINUTES);
//发送短信验证码给该手机号
System.out.println("已经给"+phone+"发送了验证码:"+code +" 发送时间:"+ new Date().toLocaleString());
//SmsUtil.sendSms(phone,"验证码为:"+code+",请在"+expireTime+"分钟之内使用!当前时间:"+
// new Date().toLocaleString());
return AjaxResult.result();
完成注册:
@Override
public AjaxResult register(UserDto userDto) {
if (StringUtils.isEmpty(userDto.getPhone()) || StringUtils.isEmpty(userDto.getSmsCode()) ||
StringUtils.isEmpty(userDto.getPassword()) || StringUtils.isEmpty(userDto.getConfirmPwd())){
return AjaxResult.result().setMessage("请完善必填信息");
}
if (!userDto.getPassword().equals(userDto.getConfirmPwd())){
return AjaxResult.result().setMessage("两次密码不一致");
}
User user = userMapper.loadByPhone(userDto.getPhone());
if (user != null){
return AjaxResult.result().setMessage("该手机号已被注册");
}
Object value = redisTemplate.opsForValue().get("registerCode" + userDto.getPhone());
if (value == null){
return AjaxResult.result().setMessage("验证码未获取或过期,请重新获取验证码");
}
String codeOnRedis = (String)value;
String code = codeOnRedis.split(":")[0];
if (!userDto.getSmsCode().equalsIgnoreCase(code)){
return AjaxResult.result().setMessage("请输入正确的验证码");
}
//把注册信息转为LoginInfo对象,然后保存数据库,返回自增的id给User对象用来新增
LoginInfo loginInfo = userDto2LoginInfo(userDto);
infoMapper.save(loginInfo);
User userTmp = loginInfo2User(loginInfo);
userMapper.save(userTmp);
return AjaxResult.result();
基本分析:后台登录(管理员账号),前台登录(用户账号/手机验证码/三方登录)
由于是前后端分离,所以不能用传统的cookie保存用户.所以这里我们采用的是token方案, 完全抛弃session,自动过期30分钟,用redis来替代
怎么让token每次都携带过去?
1)登录成功后把token存放到浏览器 localStorage
2)每次对后端的请求,都从浏览器获取token并且携带过去
对后端请求都是使用axios,使用axios前置拦截器给请求追加一个请求头,后置拦截跳转到登录界面
前端拦截 :路由拦截器(拦截一些不需要访问后端的资源)
UUID, Springmvc拦截器-springboot
localStorage ,axios前/后置拦截器, 路由拦截器
后端拦截器
@Component
public class LoginInterceptor implements HandlerInterceptor {
//判断是否有token,并在redis作用有。当然也可以做权限判断
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
String token = request.getHeader("token");
if (!StringUtils.isEmpty(token)){
System.out.println(token+" enter...................");
Object obj = redisTemplate.opsForValue().get(token);
if (obj!=null){
LoginInfo loginInfo = (LoginInfo) obj;
//更新时间,让redis中数据不过期,类似于只要点页面session不过期 redisTemplate.opsForValue().set(token,loginInfo,30, TimeUnit.MINUTES);
//@TODO 权限校验
return true;
}
}
//校验没有通过 返回一个前台能够识别错误 {"success":false,"message":'noLogin'}
response.setCharacterEncoding("utf-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write("{\"success\":false,\"message\":\"noLogin\"}");
writer.flush();
writer.close();
return false;
}
}
@Configuration
public class WebConfigurer implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
// 这个方法用来注册拦截器,我们自己写好的拦截器需要通过这里添加注册才能生效
@Override
public void addInterceptors(InterceptorRegistry registry) {
// addPathPatterns("/**") 表示拦截所有的请求,
// excludePathPatterns("/login", "/register") 表示除了登陆与注册之外,因为登陆注册不需要登陆也可以访问
registry.addInterceptor(loginInterceptor).addPathPatterns("/**").
excludePathPatterns("/login", "/register","/assets/**","/js/**","nopermission");
}
}
具体登录
@Override
public AjaxResult loginAccount(LoginDto loginDto) {
// 1 校验 null
String password = loginDto.getPassword();
if (StringUtils.isEmpty(loginDto.getUsername())||
StringUtils.isEmpty(password)||
StringUtils.isEmpty(loginDto.getLoginType()))
return AjaxResult.me().setSuccess(false).setMessage("请输入相关信息后再登录!");
//2 从数据库查询 username logintype 用户是否存在,并且状态是否OK
LoginInfo loginInfo= loginInfoMapper.loadByUserName(loginDto);
if (loginInfo==null)
return AjaxResult.me().setSuccess(false).setMessage("用户名或密码不正确!");
if (loginInfo.getDisable()!=1)
return AjaxResult.me().setSuccess(false).setMessage("用户已被禁用!请联系管理员!");
//3 进行秘码比对
String salt = loginInfo.getSalt();
String secretPwd = MD5Utils.encrypByMd5(password + salt);
if (!secretPwd.equals(loginInfo.getPassword()))
return AjaxResult.me().setSuccess(false).setMessage("用户名或密码不正确!");
//4 存放redis并返回token,其实为了前台不额外发一个请求可以把用户和token一起返回,但是密码要置空
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(token,loginInfo,30, TimeUnit.MINUTES);
// 返回token,用户,所以用map返回
Map<String,Object> result = new HashMap<>();
result.put("token",token);
loginInfo.setPassword(""); //但是密码要置空
result.put("user",loginInfo);
return AjaxResult.me().setResultObj(result);
}
axios前/后置拦截
axios.interceptors.request.use(config=>{
//携带token
let uToken = localStorage.getItem("uToken");
if(uToken){
config.headers['U-TOKEN']=uToken;
}
return config;
},error => {
Promise.reject(error);
})
//2 使用axios后置拦截器,处理没有登录请求
axios.interceptors.response.use(result=>{
console.log(result.data+"jjjjjjj");
let data = result.data;
if(!data.success && data.message==="noUser")
location.href = "/login.html";
return result;
},error => {
Promise.reject(error);
})
微信登录的原理
(A)用户访问客户端,后者将前者导向认证服务器。
(B)用户选择是否给予客户端授权。
(C)假设用户给予授权,认证服务器将用户导向客户端事先指定的"重定向URI",同时附上一个授权码。
(D)客户端收到授权码,附上早先的"重定向URI",向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。
认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)
所以首先需要微信注册,开发者认证,创建网站应用 ,获取到appid和appsecret,配置好回调域名
核心代码:
@Override
public AjaxResult loginWeixin(Map<String, String> params) {
//1获取参数
String code = params.get("code");
String binderUrl = params.get("binderUrl");
// 2 发起获取accesstoken
// 2.1获取accesstoken请求url
String url = WxConstants.GET_ACCESSTOKEN_URL
.replace("APPID",WxConstants.APPID)
.replace("SECRET",WxConstants.SECURITY)
.replace("CODE",code);
//2.2 发请求
String jsonStr = HttpClientUtils.httpGet(url);
JSONObject jsonObject = JSONObject.parseObject(jsonStr);
String accessToken = jsonObject.getString("access_token");
String openid = jsonObject.getString("openid");
System.out.println(accessToken);
System.out.println(openid);
//3 通过openid(微信号的唯一性标识)
User user = userMapper.loadByOpenId(openid);
if (user!=null){
//3.1 已经绑定,做免密登录
//4 存放redis并返回token,其实为了前台不额外发一个请求可以把用户和token一起返回,但是密码要置空
LoginInfo loginInfo = loginInfoMapper.loadById(
user.getLogininfo_id());
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(token,loginInfo,30, TimeUnit.MINUTES);
// 返回token,用户,所以用map返回
Map<String,Object> result = new HashMap<>();
result.put("token",token);
loginInfo.setPassword(""); //但是密码要置空
result.put("user",loginInfo);
return AjaxResult.me().setResultObj(result);
}else{
binderUrl=binderUrl+"?accessToken="+accessToken+"&openid="+openid;
return AjaxResult.me().setSuccess(false).setMessage("binder").setResultObj(binderUrl);
}
}
如果该微信没有对应账号,那么前台跳转到绑定页面,需要绑定/注册账号
@Override
public AjaxResult binder(LoginDto loginDto) {
String accessToken = loginDto.getAccessToken();
String openid = loginDto.getOpenid();
//1 查询微信用户信息
String url = WxConstants.GET_USER_URL
.replace("ACCESS_TOKEN",accessToken)
.replace("OPENID",openid);
String jsonStr = HttpClientUtils.httpGet(url);
User user = null;
LoginInfo loginInfo = loginInfoMapper.loadByUserName(loginDto);
//2 判断用户是否存在,用户不存在创建用户同时创建logininfo
if (loginInfo==null){
user = wxUserJsonStr2User(jsonStr,loginDto);
loginInfo = userService.addOfReturnLoginInfo(user);
}
else {
//3 如果用户存在并且密码正确,获取用户
String md5Pwd = MD5Utils.encrypByMd5(loginDto.getPassword() + loginInfo.getSalt());
System.out.println(md5Pwd);
System.out.println(loginInfo.getPassword());
if (!loginInfo.getPassword().equals(md5Pwd))
return AjaxResult.me().setSuccess(false).setMessage("用户名或密码不正确!");
user = userMapper.loadByUsername(loginDto.getUsername());
}
//4 创建微信用户,并且和用户进行绑定
WxUser wxUser = wxUserJsonStr2WxUser(jsonStr);
wxUser.setUser_id(user.getId());
wxUserMapper.save(wxUser);
//5 免密登录
String token = UUID.randomUUID().toString();
Map<String,Object> result = new HashMap<>();
result.put("token",token);
loginInfo.setPassword(""); //但是密码要置空
result.put("user",loginInfo);
return AjaxResult.me().setResultObj(result);
}
基本的CRUD,这里就不一一详细说明
:
平台提供基础的洗澡,美容,洁牙等基础服务,用户通过前台页面下单,平台把订单的推送给离用户最近的门店,由门店提供线下服务。
用户到店消费,由店员下单完成交易。
表设计:
服务表
服务详情表
CRUD基本操作
@RestController
@RequestMapping("/product")
public class ProductController {
@Autowired
private IProductService service;
@Autowired
private IProductDetailService detailService;
/**增加或修改一条数据*/
@PutMapping
public AjaxResult save(@RequestBody Product product){
try {
Long id = product.getId();
if (id == null){
service.add(product);
} else {
service.update(product);
}
return AjaxResult.result();
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.result().setMessage("保存失败---"+e.getMessage());
}
}
/**删除一条数据*/
@DeleteMapping("/{id}")
public AjaxResult delete(@PathVariable("id") Long id){
try {
service.delete(id);
return AjaxResult.result();
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.result().setMessage("删除失败---"+e.getMessage());
}
}
/**获取一条数据*/
@GetMapping("/{id}")
public Product getById(@PathVariable("id") Long id){
return service.findById(id);
}
@GetMapping("/detail/{pid}")
public ProductDetail getDetailByPid(@PathVariable("pid") Long pid){
return detailService.getDetailByPid(pid);
}
/**获取所有数据*/
@GetMapping
public List<Product> getAll(){
return service.findAll();
}
/**查询数据,axios get不支持传对象,所以用post*/
@PostMapping
public PageList<Product> list(@RequestBody ProductQuery query){
return service.queryPage(query);
}
/**只展示上架的商品*/
@PostMapping("/list")
public PageList<Product> onSaleList(@RequestBody ProductQuery query){
query.setState(1);
return service.queryPage(query);
}
/**批量删除,axios delete不支持传对象,所以用patch*/
@PatchMapping("/batch")
public AjaxResult batchRemove(@RequestBody List<Long> ids){
try {
service.batchRemove(ids);
return AjaxResult.result();
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.result().setMessage("批量删除失败"+e.getMessage());
}
}
@PatchMapping("/onSale")
public AjaxResult onSale(@RequestBody List<Long> ids){
try {
service.batchSale(ids,1);
return AjaxResult.result();
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.result().setMessage("批量上架失败"+e.getMessage());
}
}
@PatchMapping("/offSale")
public AjaxResult offSale(@RequestBody List<Long> ids){
try {
service.batchSale(ids,0);
return AjaxResult.result();
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.result().setMessage("批量下架失败"+e.getMessage());
}
}
}
注意:product的增删改,需要与product_detail同步
@Override
public void save(Product product) {
//添加时需要返回id
productMapper.save(product);
ProductDetail detail = product.getDetail();
detail.setProduct_id(product.getId());
productDetailMapper.save(detail);
}
@Override
public void update(Product product) {
productMapper.update(product);
//没有详情id 通过product_id修改productDetail
ProductDetail detail = product.getDetail();
detail.setProduct_id(product.getId());
productDetailMapper.updateByProductId(detail);
}
服务的上架下架(就是修改数据表中state的值)
@Override
public void batchSale(int flag, List<Long> ids) {
if (flag==1){
//修改上架状态及时间
Map<String,Object> params = new HashMap<>();
params.put("onsaleTime",new Date());
params.put("ids",ids);
productMapper.onsale(params);
//update t_product set onsaleTime = xx and state = 1 where id in(1,2,3)
}else{
//修改下架状态及时间
Map<String,Object> params = new HashMap<>();
params.put("offsaleTime",new Date());
params.put("ids",ids);
productMapper.offSale(params);
//update t_product set offsaleTime = xx and state = 0 where id in(1,2,3)
}
1)Springboot
2)FastDFS
3)Redis
4)短信消息
5)百度地图
6)微信三方登录
7)支付宝支付
8)加密技术
9)邮件技术
10)Quartz
前后花了几个月,总算完成了这个项目,虽然参与的部分很少,但是收获却很多. 第一次真正接触前后端分离的项目,了解vue的基本语法,element组件,docker容器部署等,对我的职业无疑是很重要的经历, 感谢项目中的所有成员,争取再接再厉