355 lines
10 KiB
Markdown
355 lines
10 KiB
Markdown
|
|
# 用户服务开发文档
|
||
|
|
|
||
|
|
## 一、Entity 实体类
|
||
|
|
|
||
|
|
### User.java
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.openclaw.entity;
|
||
|
|
|
||
|
|
import com.baomidou.mybatisplus.annotation.*;
|
||
|
|
import lombok.Data;
|
||
|
|
import java.time.LocalDateTime;
|
||
|
|
|
||
|
|
@Data
|
||
|
|
@TableName("users")
|
||
|
|
public class User {
|
||
|
|
@TableId(type = IdType.AUTO)
|
||
|
|
private Long id;
|
||
|
|
private String phone;
|
||
|
|
private String passwordHash;
|
||
|
|
private String nickname;
|
||
|
|
private String avatarUrl;
|
||
|
|
private String status; // active / inactive / banned
|
||
|
|
private String memberLevel; // normal / silver / gold / diamond
|
||
|
|
private Integer growthValue;
|
||
|
|
private String banReason; // 封禁原因
|
||
|
|
private LocalDateTime createdAt;
|
||
|
|
private LocalDateTime updatedAt;
|
||
|
|
@TableLogic
|
||
|
|
private LocalDateTime deletedAt;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### UserProfile.java
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.openclaw.entity;
|
||
|
|
|
||
|
|
import com.baomidou.mybatisplus.annotation.*;
|
||
|
|
import lombok.Data;
|
||
|
|
import java.time.LocalDate;
|
||
|
|
import java.time.LocalDateTime;
|
||
|
|
|
||
|
|
@Data
|
||
|
|
@TableName("user_profiles")
|
||
|
|
public class UserProfile {
|
||
|
|
@TableId(type = IdType.AUTO)
|
||
|
|
private Long id;
|
||
|
|
private Long userId;
|
||
|
|
private String realName;
|
||
|
|
private String idCard;
|
||
|
|
private String gender; // male / female / unknown
|
||
|
|
private LocalDate birthday;
|
||
|
|
private String city;
|
||
|
|
private String bio;
|
||
|
|
private String authStatus; // none / pending / approved / rejected
|
||
|
|
private LocalDateTime createdAt;
|
||
|
|
private LocalDateTime updatedAt;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 二、DTO / VO
|
||
|
|
|
||
|
|
### UserRegisterDTO.java
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.openclaw.dto;
|
||
|
|
|
||
|
|
import jakarta.validation.constraints.*;
|
||
|
|
import lombok.Data;
|
||
|
|
|
||
|
|
@Data
|
||
|
|
public class UserRegisterDTO {
|
||
|
|
@NotBlank(message = "手机号不能为空")
|
||
|
|
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||
|
|
private String phone;
|
||
|
|
|
||
|
|
@NotBlank(message = "密码不能为空")
|
||
|
|
@Size(min = 6, max = 20, message = "密码长度6-20位")
|
||
|
|
private String password;
|
||
|
|
|
||
|
|
@NotBlank(message = "验证码不能为空")
|
||
|
|
private String smsCode;
|
||
|
|
|
||
|
|
private String inviteCode; // 邀请码(可选)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### UserLoginDTO.java
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.openclaw.dto;
|
||
|
|
|
||
|
|
import jakarta.validation.constraints.NotBlank;
|
||
|
|
import lombok.Data;
|
||
|
|
|
||
|
|
@Data
|
||
|
|
public class UserLoginDTO {
|
||
|
|
@NotBlank(message = "手机号不能为空")
|
||
|
|
private String phone;
|
||
|
|
|
||
|
|
@NotBlank(message = "密码不能为空")
|
||
|
|
private String password;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### UserUpdateDTO.java
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.openclaw.dto;
|
||
|
|
|
||
|
|
import lombok.Data;
|
||
|
|
import java.time.LocalDate;
|
||
|
|
|
||
|
|
@Data
|
||
|
|
public class UserUpdateDTO {
|
||
|
|
private String nickname;
|
||
|
|
private String avatarUrl; // 腾讯云COS上传后的URL
|
||
|
|
private String gender;
|
||
|
|
private LocalDate birthday;
|
||
|
|
private String city;
|
||
|
|
private String bio;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### UserVO.java
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.openclaw.vo;
|
||
|
|
|
||
|
|
import lombok.Data;
|
||
|
|
import java.time.LocalDateTime;
|
||
|
|
|
||
|
|
@Data
|
||
|
|
public class UserVO {
|
||
|
|
private Long id;
|
||
|
|
private String phone;
|
||
|
|
private String nickname;
|
||
|
|
private String avatarUrl;
|
||
|
|
private String memberLevel;
|
||
|
|
private Integer growthValue;
|
||
|
|
private Integer availablePoints;
|
||
|
|
private String inviteCode;
|
||
|
|
private LocalDateTime createdAt;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### LoginVO.java
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.openclaw.vo;
|
||
|
|
|
||
|
|
import lombok.Data;
|
||
|
|
|
||
|
|
@Data
|
||
|
|
public class LoginVO {
|
||
|
|
private String token;
|
||
|
|
private UserVO user;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 三、Service 接口
|
||
|
|
|
||
|
|
### UserService.java
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.openclaw.service;
|
||
|
|
|
||
|
|
import com.openclaw.dto.*;
|
||
|
|
import com.openclaw.vo.*;
|
||
|
|
|
||
|
|
public interface UserService {
|
||
|
|
void sendSmsCode(String phone);
|
||
|
|
LoginVO register(UserRegisterDTO dto);
|
||
|
|
LoginVO login(UserLoginDTO dto);
|
||
|
|
void logout(String token);
|
||
|
|
UserVO getCurrentUser(Long userId);
|
||
|
|
UserVO updateProfile(Long userId, UserUpdateDTO dto);
|
||
|
|
void changePassword(Long userId, String oldPassword, String newPassword);
|
||
|
|
void resetPassword(String phone, String smsCode, String newPassword);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 四、Service 实现(核心逻辑)
|
||
|
|
|
||
|
|
### UserServiceImpl.java
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.openclaw.service.impl;
|
||
|
|
|
||
|
|
import com.openclaw.constant.ErrorCode;
|
||
|
|
import com.openclaw.dto.*;
|
||
|
|
import com.openclaw.entity.*;
|
||
|
|
import com.openclaw.exception.BusinessException;
|
||
|
|
import com.openclaw.repository.*;
|
||
|
|
import com.openclaw.service.*;
|
||
|
|
import com.openclaw.util.JwtUtil;
|
||
|
|
import com.openclaw.vo.*;
|
||
|
|
import lombok.RequiredArgsConstructor;
|
||
|
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||
|
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||
|
|
import org.springframework.stereotype.Service;
|
||
|
|
import org.springframework.transaction.annotation.Transactional;
|
||
|
|
import java.util.concurrent.TimeUnit;
|
||
|
|
|
||
|
|
@Service
|
||
|
|
@RequiredArgsConstructor
|
||
|
|
public class UserServiceImpl implements UserService {
|
||
|
|
|
||
|
|
private final UserRepository userRepository;
|
||
|
|
private final UserProfileRepository userProfileRepository;
|
||
|
|
private final UserPointsRepository userPointsRepository;
|
||
|
|
private final InviteCodeRepository inviteCodeRepository;
|
||
|
|
private final PointsService pointsService;
|
||
|
|
private final InviteService inviteService;
|
||
|
|
private final PasswordEncoder passwordEncoder;
|
||
|
|
private final StringRedisTemplate redisTemplate;
|
||
|
|
private final JwtUtil jwtUtil;
|
||
|
|
|
||
|
|
@Override
|
||
|
|
public void sendSmsCode(String phone) {
|
||
|
|
String code = String.valueOf((int)((Math.random() * 9 + 1) * 100000));
|
||
|
|
redisTemplate.opsForValue().set("captcha:sms:" + phone, code, 5, TimeUnit.MINUTES);
|
||
|
|
// TODO: 调用腾讯云短信SDK发送
|
||
|
|
}
|
||
|
|
|
||
|
|
@Override
|
||
|
|
@Transactional
|
||
|
|
public LoginVO register(UserRegisterDTO dto) {
|
||
|
|
// 1. 校验短信验证码
|
||
|
|
String cached = redisTemplate.opsForValue().get("captcha:sms:" + dto.getPhone());
|
||
|
|
if (!dto.getSmsCode().equals(cached)) throw new BusinessException(ErrorCode.SMS_CODE_ERROR);
|
||
|
|
|
||
|
|
// 2. 手机号唯一性检查
|
||
|
|
if (userRepository.existsByPhone(dto.getPhone())) throw new BusinessException(ErrorCode.PHONE_ALREADY_EXISTS);
|
||
|
|
|
||
|
|
// 3. 创建用户
|
||
|
|
User user = new User();
|
||
|
|
user.setPhone(dto.getPhone());
|
||
|
|
user.setPasswordHash(passwordEncoder.encode(dto.getPassword()));
|
||
|
|
user.setNickname("用户" + dto.getPhone().substring(7));
|
||
|
|
user.setStatus("active");
|
||
|
|
user.setMemberLevel("normal");
|
||
|
|
user.setGrowthValue(0);
|
||
|
|
userRepository.save(user);
|
||
|
|
|
||
|
|
// 4. 初始化资料
|
||
|
|
UserProfile profile = new UserProfile();
|
||
|
|
profile.setUserId(user.getId());
|
||
|
|
profile.setAuthStatus("none");
|
||
|
|
userProfileRepository.save(profile);
|
||
|
|
|
||
|
|
// 5. 初始化积分 + 注册奖励
|
||
|
|
pointsService.initUserPoints(user.getId());
|
||
|
|
pointsService.earnPoints(user.getId(), "register", null, null);
|
||
|
|
|
||
|
|
// 6. 邀请码处理
|
||
|
|
if (dto.getInviteCode() != null) {
|
||
|
|
inviteService.handleInviteRegister(dto.getInviteCode(), user.getId());
|
||
|
|
}
|
||
|
|
|
||
|
|
// 7. 生成自己的邀请码
|
||
|
|
inviteService.generateInviteCode(user.getId());
|
||
|
|
|
||
|
|
// 8. 清除验证码
|
||
|
|
redisTemplate.delete("captcha:sms:" + dto.getPhone());
|
||
|
|
|
||
|
|
return buildLoginVO(user);
|
||
|
|
}
|
||
|
|
|
||
|
|
@Override
|
||
|
|
public LoginVO login(UserLoginDTO dto) {
|
||
|
|
User user = userRepository.findByPhone(dto.getPhone())
|
||
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||
|
|
if ("banned".equals(user.getStatus())) throw new BusinessException(ErrorCode.USER_BANNED);
|
||
|
|
if (!passwordEncoder.matches(dto.getPassword(), user.getPasswordHash()))
|
||
|
|
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
|
||
|
|
return buildLoginVO(user);
|
||
|
|
}
|
||
|
|
|
||
|
|
@Override
|
||
|
|
public void logout(String token) {
|
||
|
|
long remaining = jwtUtil.getExpiration(token);
|
||
|
|
redisTemplate.opsForValue().set("user:token:" + token, "1", remaining, TimeUnit.SECONDS);
|
||
|
|
}
|
||
|
|
|
||
|
|
@Override
|
||
|
|
public UserVO getCurrentUser(Long userId) {
|
||
|
|
User user = userRepository.getById(userId);
|
||
|
|
if (user == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||
|
|
return buildUserVO(user);
|
||
|
|
}
|
||
|
|
|
||
|
|
@Override
|
||
|
|
@Transactional
|
||
|
|
public UserVO updateProfile(Long userId, UserUpdateDTO dto) {
|
||
|
|
User user = userRepository.getById(userId);
|
||
|
|
if (dto.getNickname() != null) user.setNickname(dto.getNickname());
|
||
|
|
if (dto.getAvatarUrl() != null) user.setAvatarUrl(dto.getAvatarUrl());
|
||
|
|
userRepository.updateById(user);
|
||
|
|
|
||
|
|
UserProfile p = userProfileRepository.findByUserId(userId);
|
||
|
|
if (dto.getGender() != null) p.setGender(dto.getGender());
|
||
|
|
if (dto.getBirthday() != null) p.setBirthday(dto.getBirthday());
|
||
|
|
if (dto.getCity() != null) p.setCity(dto.getCity());
|
||
|
|
if (dto.getBio() != null) p.setBio(dto.getBio());
|
||
|
|
userProfileRepository.updateById(p);
|
||
|
|
return buildUserVO(user);
|
||
|
|
}
|
||
|
|
|
||
|
|
@Override
|
||
|
|
public void changePassword(Long userId, String oldPwd, String newPwd) {
|
||
|
|
User user = userRepository.getById(userId);
|
||
|
|
if (!passwordEncoder.matches(oldPwd, user.getPasswordHash()))
|
||
|
|
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
|
||
|
|
user.setPasswordHash(passwordEncoder.encode(newPwd));
|
||
|
|
userRepository.updateById(user);
|
||
|
|
}
|
||
|
|
|
||
|
|
@Override
|
||
|
|
public void resetPassword(String phone, String smsCode, String newPassword) {
|
||
|
|
String cached = redisTemplate.opsForValue().get("captcha:sms:" + phone);
|
||
|
|
if (!smsCode.equals(cached)) throw new BusinessException(ErrorCode.SMS_CODE_ERROR);
|
||
|
|
User user = userRepository.findByPhone(phone)
|
||
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||
|
|
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||
|
|
userRepository.updateById(user);
|
||
|
|
redisTemplate.delete("captcha:sms:" + phone);
|
||
|
|
}
|
||
|
|
|
||
|
|
private LoginVO buildLoginVO(User user) {
|
||
|
|
LoginVO vo = new LoginVO();
|
||
|
|
vo.setToken(jwtUtil.generateToken(user.getId()));
|
||
|
|
vo.setUser(buildUserVO(user));
|
||
|
|
return vo;
|
||
|
|
}
|
||
|
|
|
||
|
|
private UserVO buildUserVO(User user) {
|
||
|
|
UserVO vo = new UserVO();
|
||
|
|
vo.setId(user.getId());
|
||
|
|
vo.setPhone(user.getPhone());
|
||
|
|
vo.setNickname(user.getNickname());
|
||
|
|
vo.setAvatarUrl(user.getAvatarUrl());
|
||
|
|
vo.setMemberLevel(user.getMemberLevel());
|
||
|
|
vo.setGrowthValue(user.getGrowthValue());
|
||
|
|
vo.setCreatedAt(user.getCreatedAt());
|
||
|
|
UserPoints pts = userPointsRepository.findByUserId(user.getId());
|
||
|
|
if (pts != null) vo.setAvailablePoints(pts.getAvailablePoints());
|
||
|
|
InviteCode ic = inviteCodeRepository.findByUserId(user.getId());
|
||
|
|
if (ic != null) vo.setInviteCode(ic.getCode());
|
||
|
|
return vo;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|