群组功能实现指南:从数据库设计到前后端交互,上班第二周

发布于:2025-07-21 ⋅ 阅读:(13) ⋅ 点赞:(0)

学到这里,我需要梳理一下我目前学习登录注册的逻辑问题。

用户管理系统的流程curd

注册

前端提交的注册数据,给到后端服务,
用户名是否重复,可以在添加数据表单时id添加唯一索引,也可以在接口书写是否已注册,返回未注册,
密码进行加密,写入用户数据(加密密码,创建时间),返回写入成功,关于邮箱注册与手机号注册,我还没有尝试过。待定

关于如何登录
前端页面提交数据(密码,用户信息),到数据库中查看用户信息是否存在,密码进行加密,查看加密密码与数据库的密码是否一致。
推荐使用 BCrypt(自动生成盐值,无需单独存储)

// 注册时加密
String encryptedPwd = new BCryptPasswordEncoder().encode(rawPassword);

// 登录时校验
boolean isMatch = new BCryptPasswordEncoder().matches(rawPassword, dbPassword);

练习可以使用 MD5(需自己处理盐值,已逐渐被淘汰)
因为数据不大
在这里插入图片描述

我需要将校验用户名是否存在数据库中,检测密码时需要加密,可以加盐,验证数据库的密码hash值,对比。
注册就是,用户名,密码,确认密码,

唯一标识校验
手机号 / 邮箱 / 用户名需全局唯一

-- 数据库唯一索引
ALTER TABLE `user` ADD UNIQUE `idx_user_phone` (`user_phone`);

-- 后端查询校验
User existUser = userMapper.findByPhone(userPhone);
if (existUser != null) {
    throw new BizException("手机号已注册");
}

下面就是注意的一点,关于登录的需要注意的点,我可以允许他存在一台设备上,当进行二次登录,将第一次登录的设备会被弹出,这个时候的

  1. 登录态保持(Token 机制)
    方案:JWT(JSON Web Token)
    流程:
    登录成功时生成 JWT:
// JJWT 库示例
String token = Jwts.builder()
    .setSubject(userId)
    .setExpiration(new Date(System.currentTimeMillis() + 3600 * 1000)) // 1小时过期
    .signWith(SignatureAlgorithm.HS256, "your-secret-key")
    .compact();

前端存储 Token(localStorage 或 Cookie)
后续请求携带 Token(Authorization 头)


com.bushuo.bushuopicturebackend.exception.BusinessException: 用户不存在或密码错误
在这里插入图片描述
数据库也是userAccount
在这里插入图片描述

在使用 QueryWrapper 进行查询时遇到了“用户不存在或密码错误”的异常。

现在的问题在于 userAccount 字段在数据库中的实际列名为 user_account,而代码中直接使用了 userAccount 导致字段映射不一致。

解决方案

  1. 字段映射不一致:在 MyBatis Plus 中,如果实体类字段名与数据库表的实际列名不完全一致(包括大小写),需要使用 @TableField 注解进行显式映射。
  2. 推荐使用 LambdaQueryWrapper:相比 QueryWrapperLambdaQueryWrapper 提供更高的类型安全性和可维护性,避免字段名拼写错误。

修改代码

1. 在 User 实体类中添加 @TableField 注解

首先需要在 User 实体类中对 userAccount 字段添加 @TableField 注解,明确指定其对应的数据库列名。

2. 使用 LambdaQueryWrapper 进行查询

UserServiceImpl 类的 userLogin 方法中,推荐使用 LambdaQueryWrapper 来提高查询的安全性和可维护性。

问题小结

  • 修改点 1:在 User 实体类中为 userAccount 字段添加 @TableField("user_account") 注解,确保字段映射正确。
  • 修改点 2:在 UserServiceImpluserLogin 方法中,使用 LambdaQueryWrapper 替代 QueryWrapper,提高查询的安全性和可维护性。

这样可以有效解决由于字段映射不一致导致的“用户不存在或密码错误”问题。

其实只需要将数据库名称的下划线由_变成-这个差不多就可以了
在这里插入图片描述

关于数据库,我安装了宝塔连接数据库后,不知道为什么数据库密码变了,我查了一轮,发现密码在宝塔里面,因为我不知道为什么重置mysql密码不行,我一看我的数据库密码。
在这里插入图片描述
密码也可以修改,关于数据库8.0版本,我看了需要修改地区时效

spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/bushuo-picture?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456

他还要我修改地址,将utc换成Asia/Shanghai,8.0以上需要。
在这里插入图片描述
还有数据库的驱动下载
在这里插入图片描述
在这里插入图片描述
寻找驱动下载,可以将5.0的删掉“-”;
解决了一点点问题。挺开心的。


添加群功能

✅ 可行性结论

模块 可行性 说明
数据库设计 ✅✅✅ 表结构合理、索引完备、支持软删除与角色扩展
后端实现(Spring Boot + MyBatis) ✅✅✅ 分层清晰、事务控制、接口规范、易于维护
可扩展性 ✅✅✅ 支持角色、权限、昵称、禁言等后续功能扩展
安全性与健壮性 ✅✅ 提供了 JWT 校验、幂等性、状态管理等安全机制
团队协作与文档规范 ✅✅ 接口命名规范、代码结构清晰,适合多人协作

🧩 补充

1. 数据迁移脚本(如从 public_chat_status 迁移)

如果你之前使用了 public_chat_status 来记录用户群组关系,可以写一个迁移脚本将有效数据迁移到 group_members 中:

INSERT INTO group_members (group_id, user_id, role, status)
SELECT group_id, user_id, 'member', 1
FROM public_chat_status
WHERE status = 1;
  • 后续逐步弃用 public_chat_status 的相关逻辑。

2. 枚举字段定义建议(MySQL ENUM 或 Java 枚举类)

MySQL 层面定义:
role ENUM('member', 'admin', 'owner') DEFAULT 'member'
Java 层面定义:
public enum GroupRole {
    MEMBER("member"),
    ADMIN("admin"),
    OWNER("owner");

    private final String value;

    GroupRole(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }
}
  • 使用枚举能提升代码可读性和类型安全性。

3. 幂等性处理(防止重复加群)

在 Controller 层或 Service 层做幂等判断:

if (groupMembersMapper.findByGroupIdAndUserId(groupId, userId) != null) {
    return ResponseEntity.badRequest().body("您已在该群中");
}
  • 防止前端误操作或网络重发导致的重复请求。

4. 统一返回结构(推荐)

统一使用 Result<T> 返回结构,便于前端解析:

public class Result<T> {
    private int code;
    private String message;
    private T data;

    // success / error 方法
}

示例返回:

return Result.success("加入成功");

5. 日志与监控建议

  • 在关键操作(如加群、退群)中添加日志记录。
  • 可结合 AOP 实现接口调用日志追踪。
  • 若接入监控系统(如 Prometheus),可记录加群频率、失败次数等指标。

  1. 数据库建表
CREATE TABLE group_members (
  id INT PRIMARY KEY AUTO_INCREMENT,
  group_id INT NOT NULL,
  user_id INT NOT NULL,
  join_time DATETIME DEFAULT CURRENT_TIMESTAMP,
  role ENUM('member', 'admin', 'owner') DEFAULT 'member',
  status INT DEFAULT 1, -- 1=正常,0=退出
  UNIQUE KEY uq_group_user (group_id, user_id),
  KEY idx_group_id (group_id),
  KEY idx_user_id (user_id)
);
  1. Java 实体类
package com.life.lifechat.domain;

import lombok.Data;

import java.util.Date;

@Data
public class GroupMembers {
    private Integer id;
    private Integer groupId;
    private Integer userId;
    private Date joinTime;
    private String role;
    private Integer status;

    // getters and setters
}
  1. Mapper 接口
package com.life.lifechat.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.life.lifechat.domain.GroupMembers;
import org.apache.ibatis.annotations.Param;
import java.util.List;

public interface GroupMembersMapper extends BaseMapper<GroupMembers> {

    GroupMembers findByGroupIdAndUserId(@Param("groupId") Integer groupId, @Param("userId") Integer userId);

    List<GroupMembers> findByGroupId(@Param("groupId") Integer groupId);

    List<GroupMembers> findByUserId(@Param("userId") Integer userId);

    int updateStatusByGroupIdAndUserId(@Param("groupId") Integer groupId, @Param("userId") Integer userId, @Param("status") Integer status);
}

四、Mapper XML

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.life.lifechat.mapper.GroupMembersMapper">

    <select id="findByGroupIdAndUserId" resultType="com.life.lifechat.domain.GroupMembers">
        SELECT * FROM group_members WHERE group_id = #{groupId} AND user_id = #{userId} AND status = 1
    </select>

    <select id="findByGroupId" resultType="com.life.lifechat.domain.GroupMembers">
        SELECT * FROM group_members WHERE group_id = #{groupId} AND status = 1
    </select>

    <select id="findByUserId" resultType="com.life.lifechat.domain.GroupMembers">
        SELECT * FROM group_members WHERE user_id = #{userId} AND status = 1
    </select>

    <update id="updateStatusByGroupIdAndUserId">
        UPDATE group_members SET status = #{status}
        WHERE group_id = #{groupId} AND user_id = #{userId}
    </update>

</mapper>

五、Service 层
新建 GroupMembersService.java

package com.life.lifechat.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.life.lifechat.domain.GroupMembers;

/**
 * 群成员服务接口
 */
public interface GroupMembersService extends IService<GroupMembers> {
}


  1. 新建实现类 GroupMembersServiceImpl.java
package com.life.lifechat.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.life.lifechat.domain.GroupMembers;
import com.life.lifechat.mapper.GroupMembersMapper;
import com.life.lifechat.service.GroupMembersService;
import org.springframework.stereotype.Service;

@Service
public class GroupMembersServiceImpl extends ServiceImpl<GroupMembersMapper, GroupMembers> implements GroupMembersService {
}

✅ 在原有 GroupsService 中调用新服务
如果你在 GroupsService 中需要操作群成员数据,可以注入 GroupMembersService:

package com.life.lifechat.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.life.lifechat.domain.Groups;
import com.life.lifechat.domain.GroupMembers;
import com.life.lifechat.dto.GroupWithLastMessageDTO;

import java.util.List;

/**
 * 原有群组服务接口
 */
public interface GroupsService extends IService<Groups> {

    List<GroupWithLastMessageDTO> getGroupsWithLastMessage(Integer userId);

    // 示例方法:获取用户加入的群成员列表
    List<GroupMembers> getUserGroups(Integer userId);
}

对应的实现类中注入并使用:

@Autowired
private GroupMembersService groupMembersService;

@Override
public List<GroupMembers> getUserGroups(Integer userId) {
    return groupMembersService.listByUserId(userId); // 需要你在 GroupMembersService 中定义该方法
}

✅ 补充:在 GroupMembersService 中加常用方法
修改 GroupMembersService.java

public interface GroupMembersService extends IService<GroupMembers> {
    List<GroupMembers> listByGroupId(Integer groupId);
    List<GroupMembers> listByUserId(Integer userId);
    boolean joinGroup(Integer groupId, Integer userId);
    boolean quitGroup(Integer groupId, Integer userId);
}

修改 GroupMembersServiceImpl.java

@Override
public List<GroupMembers> listByGroupId(Integer groupId) {
    return lambdaQuery()
            .eq(GroupMembers::getGroupId, groupId)
            .eq(GroupMembers::getStatus, 1)
            .list();
}

@Override
public List<GroupMembers> listByUserId(Integer userId) {
    return lambdaQuery()
            .eq(GroupMembers::getUserId, userId)
            .eq(GroupMembers::getStatus, 1)
            .list();
}

@Override
@Transactional
public boolean joinGroup(Integer groupId, Integer userId) {
    GroupMembers existing = lambdaQuery()
            .eq(GroupMembers::getGroupId, groupId)
            .eq(GroupMembers::getUserId, userId)
            .eq(GroupMembers::getStatus, 1)
            .one();

    if (existing != null) return false;

    GroupMembers member = new GroupMembers();
    member.setGroupId(groupId);
    member.setUserId(userId);
    member.setRole("member");
    member.setStatus(1);

    return save(member);
}

@Override
public boolean quitGroup(Integer groupId, Integer userId) {
    return lambdaUpdate()
            .eq(GroupMembers::getGroupId, groupId)
            .eq(GroupMembers::getUserId, userId)
            .set(GroupMembers::getStatus, 0)
            .update();
}

加群前端代码

前端目录
src/
├── api/ # 接口封装
│ ├── axios.js # Axios 实例配置
│ └── group.js # 群相关接口
├── views/ # 页面组件
│ ├── Groups.vue # 群列表页
│ └── GroupChat.vue # 群聊天页(可选)
├── store/ # 状态管理
│ └── userStore.js # 用户信息存储
├── utils/ # 工具函数
│ └── errorHandler.js # 统一错误处理
├── router/ # 路由配置
│ └── index.js
├── App.vue
└── main.js

  1. axios.js:统一请求配置
// src/api/axios.js
import axios from 'axios';

const instance = axios.create({
  baseURL: import.meta.env.VITE_API_URL || '/api',
  timeout: 5000,
});

instance.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default instance;

  1. group.js:群相关 API 封装
// src/api/group.js
import axios from '@/api/axios';

export function joinGroup(groupId) {
  return axios.post(`/groups/${groupId}/join`);
}

export function getGroupList() {
  return axios.get('/groups/list');
}

  1. errorHandler.js:统一错误提示工具
// src/utils/errorHandler.js
import { ElMessage } from 'element-plus';

export function handleApiError(error, defaultMessage = '操作失败') {
  const message = error.response?.data?.message || defaultMessage;
  ElMessage.error(message);
}

  1. userStore.js:用户状态管理(Pinia)
// src/store/userStore.js
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    userId: null,
    nickname: '',
    avatar: '',
  }),
  actions: {
    setUser(userInfo) {
      Object.assign(this, userInfo);
    },
    clearUser() {
      this.userId = null;
      this.nickname = '';
      this.avatar = '';
    }
  }
});

  1. Groups.vue:群列表页面组件
<template>
  <div class="groups-container">
    <h2>群聊列表</h2>
    <el-table :data="groupList" border style="width: 100%">
      <el-table-column prop="groupName" label="群名称" />
      <el-table-column label="操作">
        <template #default="scope">
          <el-button
            type="primary"
            @click="handleJoin(scope.row.id)"
            :loading="loading"
            :disabled="scope.row.joined"
          >
            {{ scope.row.joined ? '已加入' : '加入群聊' }}
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useUserStore } from '@/store/userStore';
import { useRouter } from 'vue-router';
import { joinGroup, getGroupList } from '@/api/group';
import { handleApiError } from '@/utils/errorHandler';

const userStore = useUserStore();
const router = useRouter();

const groupList = ref([]);
const loading = ref(false);

onMounted(() => {
  fetchGroupList();
});

function fetchGroupList() {
  getGroupList()
    .then(res => {
      groupList.value = res.data.data || [];
    })
    .catch(err => {
      handleApiError(err, '获取群列表失败');
    });
}

function handleJoin(groupId) {
  loading.value = true;
  joinGroup(groupId)
    .then(res => {
      ElMessage.success(res.data.message || '加入成功');
      const group = groupList.value.find(g => g.id === groupId);
      if (group) group.joined = true;

      // 跳转到群聊天页
      router.push({ name: 'GroupChat', params: { groupId } });
    })
    .catch(err => {
      handleApiError(err, '加入失败');
    })
    .finally(() => {
      loading.value = false;
    });
}
</script>

<style scoped>
.groups-container {
  padding: 20px;
}
</style>

  1. router/index.js:路由配置
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import Groups from '../views/Groups.vue';
import GroupChat from '../views/GroupChat.vue';

const routes = [
  { path: '/groups', component: Groups, name: 'Groups' },
  { path: '/group/:groupId/chat', component: GroupChat, name: 'GroupChat' },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;
  1. main.js:主入口文件
// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createPinia } from '@pinia/vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';

const app = createApp(App);
const pinia = createPinia();

app.use(router);
app.use(pinia);
app.use(ElementPlus);

app.mount('#app');

在这里插入图片描述
1.运行
npm install pinia

2.修改 src/main.js:
import { createPinia } from ‘pinia’;
// …
app.use(createPinia());
在这里插入图片描述
解决方法

  1. 用的是 Vue CLI(不是 Vite)
  2. 如何修复?
import axios from 'axios';

// Vue CLI 用 process.env.VUE_APP_API_URL
const baseURL = process.env.VUE_APP_API_URL || '/api';

const instance = axios.create({
  baseURL,
  timeout: 5000,
});

instance.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default instance;

后续在index修改添加加群按钮
你输入内容,点击“搜索”按钮,前端请求 /groups/list?keyword=xxx。
后端返回群聊列表,前端渲染出来。
点击“加入群聊”按钮,前端请求 /groups/{groupId}/join,成功后按钮变为“已加入”。
/groups/list?keyword=1

新增“全局群搜索”接口 /groups/list

✅ 一、新增接口:/groups/list 用于全局群搜索

  1. Controller 层
// GroupsController.java

@GetMapping("/groups/list")
public Map<String, Object> searchGroups(@RequestParam String keyword) {
    Map<String, Object> result = new HashMap<>();
    try {
        List<GroupWithLastMessageDTO> groups = groupsService.searchGroups(keyword);
        result.put("code", 200);
        result.put("msg", "success");
        result.put("data", groups);
    } catch (Exception e) {
        result.put("code", 500);
        result.put("msg", "系统繁忙,请稍后重试");
    }
    return result;
}

✅ 二、Service 层

  1. GroupsService.java 接口定义
// GroupsService.java
List<GroupWithLastMessageDTO> searchGroups(String keyword);
  1. GroupsServiceImpl.java 实现类
// GroupsServiceImpl.java
@Autowired
private GroupsMapper groupsMapper;

@Override
public List<GroupWithLastMessageDTO> searchGroups(String keyword) {
    return groupsMapper.searchGroups(keyword);
}

✅ 三、Mapper 层

  1. GroupsMapper.java
// GroupsMapper.java
List<GroupWithLastMessageDTO> searchGroups(String keyword);

  1. GroupsMapper.xml
<!-- GroupsMapper.xml -->
<select id="searchGroups" parameterType="string" resultType="com.life.lifechat.dto.GroupWithLastMessageDTO">
    SELECT 
        g.id,
        g.group_name AS groupName,
        g.group_img AS groupImg,
        NULL AS lastMessage, -- 可选字段,加群页可能不需要显示最后一条消息
        NULL AS time
    FROM groups g
    WHERE g.group_name LIKE CONCAT('%', #{keyword}, '%')
</select>

✅ 四、DTO 类定义

// GroupWithLastMessageDTO.java
package com.life.lifechat.dto;

import java.util.Date;

public class GroupWithLastMessageDTO {
    private Integer id;
    private String groupName;
    private String groupImg;
    private String lastMessage;
    private Date time;

    // Getters and Setters
}

SELECT
g.id AS id,
g.group_name AS groupName,
g.group_img AS groupImg FROM groups g WHERE g.group_name LIKE ‘%技术%’

梳理如何将“加群/退群”功能完整落地,

直接集成和运行。

  1. 后端实现(Spring Boot + MyBatis Plus)
    1.1 DTO
    在 lifeChat/src/main/java/com/life/lifechat/dto/ 下新建 GroupJoinDTO.java
package com.life.lifechat.dto;

public class GroupJoinDTO {
    private Integer groupId;
    private Integer userId;
    // getters and setters
}

1.2 Mapper
在 GroupMembersMapper.java 中补充:

  @Select("SELECT * FROM group_members WHERE group_id = #{groupId} AND user_id = #{userId} AND status = 1")
    GroupMembers selectByGroupIdAndUserId(@Param("groupId") Integer groupId, @Param("userId") Integer userId);

    @Update("UPDATE group_members SET status = 0 WHERE group_id = #{groupId} AND user_id = #{userId}")
    int quitGroup(@Param("groupId") Integer groupId, @Param("userId") Integer userId);


  1. Service 层
    GroupMembersService.java
public interface GroupMembersService {
    boolean joinGroup(Integer groupId, Integer userId);
    boolean quitGroup(Integer groupId, Integer userId);
}

实现 GroupMembersServiceImpl.java:

@Service
public class GroupMembersServiceImpl implements GroupMembersService {
  
   @Override
    public boolean joinGroup(Integer groupId, Integer userId) {
        GroupMembers existing = baseMapper.selectByGroupIdAndUserId(groupId, userId);
        if (existing != null && existing.getStatus() == 1) {
            return false;
        }
        GroupMembers member = new GroupMembers();
        member.setGroupId(groupId);
        member.setUserId(userId);
        member.setRole("member");
        member.setStatus(1);
        return baseMapper.insert(member) > 0; }

    @Override
    public boolean quitGroup(Integer groupId, Integer userId) {
        return baseMapper.quitGroup(groupId, userId) > 0;
    }
}

1.4 Controller
在 GroupsController.java 中添加:

 @Autowired
    private GroupMembersService groupMembersService; // 👈 注意变量名和类型

  @PostMapping("/groups/join")
    public Map<String, Object> joinGroup(@RequestBody GroupJoinDTO dto) {
        Map<String, Object> result = new HashMap<>();
        try {
            boolean success = groupMembersService.joinGroup(dto.getGroupId(), dto.getUserId());
            if (success) {
                result.put("code", 200);
                result.put("msg", "加入成功");
            } else {
                result.put("code", 400);
                result.put("msg", "您已在该群中或群不存在");
            }
        } catch (Exception e) {
            e.printStackTrace();
            result.put("code", 500);
            result.put("msg", "系统繁忙,请稍后再试");
        }
        return result;
    }

    @PostMapping("/groups/quit")
    public Map<String, Object> quitGroup(@RequestBody GroupJoinDTO dto) {
        Map<String, Object> result = new HashMap<>();
        try {
            boolean success = groupMembersService.quitGroup(dto.getGroupId(), dto.getUserId());
            if (success) {
                result.put("code", 200);
                result.put("msg", "退出成功");
            } else {
                result.put("code", 400);
                result.put("msg", "退出失败,请确认是否在群中");
            }
        } catch (Exception e) {
            e.printStackTrace();
            result.put("code", 500);
            result.put("msg", "系统繁忙,请稍后再试");
        }
        return result;
    }

总结

完成登录注册基本流程,包括 BCrypt 密码加密、JWT Token 生成与验证、数据库交互,并解决了字段映射不一致(@TableField注解)和 MySQL 8.0 连接(时区 + 驱动)问题。
明确了数据库设计(group_members表)、前后端实现步骤(实体类 / 接口 / 页面 / 状态管理),定义了核心逻辑(加群查重、退群软删除)和调试方法。
待弥补 ,优化。Token 管理优化与 加密与安全增强、 前后端交互与用户体验


网站公告

今日签到

点亮在社区的每一天
去签到