feat: 全量更新前后端代码及文档 - 社区/定制/优惠券/活动/会员等模块
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
# 后端架构设计文档索引
|
||||
|
||||
> **状态**:后端开发已完成,本目录保留架构设计与数据库参考文档。
|
||||
> 实现细节请直接参阅源码:`openclaw-backend/openclaw-backend/src/`
|
||||
|
||||
## 架构总览
|
||||
|
||||
| 文件 | 说明 |
|
||||
@@ -7,8 +10,6 @@
|
||||
| [01-单体架构总体设计.md](./01-单体架构总体设计.md) | 整体架构图、技术栈、项目结构、模块划分、API格式、错误码 |
|
||||
| [01-单体架构设计.md](./01-单体架构设计.md) | 补充架构说明 |
|
||||
|
||||
---
|
||||
|
||||
## 数据库设计
|
||||
|
||||
| 文件 | 说明 |
|
||||
@@ -16,51 +17,6 @@
|
||||
| [02-数据库设计-用户Skill积分.md](./02-数据库设计-用户Skill积分.md) | users / skill_categories / skills / skill_reviews / skill_downloads / user_points / points_records / points_rules 表结构 |
|
||||
| [03-数据库设计-订单支付邀请.md](./03-数据库设计-订单支付邀请.md) | orders / order_items / order_refunds / recharge_orders / payment_records / invite_codes / invite_records 表结构 |
|
||||
|
||||
---
|
||||
|
||||
## 服务开发文档
|
||||
|
||||
### 用户服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [04-用户服务开发文档-part1.md](./04-用户服务开发文档-part1.md) | Entity / DTO / VO / Repository |
|
||||
| [04-用户服务开发文档-part2.md](./04-用户服务开发文档-part2.md) | UserService 接口 + Impl + Controller |
|
||||
|
||||
### Skill 服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [05-Skill服务开发文档.md](./05-Skill服务开发文档.md) | Entity / DTO / VO / Repository / Service / Controller |
|
||||
|
||||
### 积分服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [06-积分服务开发文档.md](./06-积分服务开发文档.md) | Entity / DTO / VO / Repository / Service / Controller |
|
||||
|
||||
### 订单服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [07-订单服务开发文档-part1.md](./07-订单服务开发文档-part1.md) | Entity / DTO / VO / Repository / Service接口 |
|
||||
| [07-订单服务开发文档-part2.md](./07-订单服务开发文档-part2.md) | OrderServiceImpl + OrderController |
|
||||
|
||||
### 支付服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [08-支付服务开发文档.md](./08-支付服务开发文档.md) | RechargeOrder / PaymentRecord / RechargeConfig / PaymentService + Impl + Controller |
|
||||
|
||||
### 邀请服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [09-邀请服务开发文档.md](./09-邀请服务开发文档.md) | InviteCode / InviteRecord / Repository / InviteService + Impl + Controller + 流程图 |
|
||||
|
||||
### 管理后台
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [10-管理后台-part1-权限与DTO.md](./10-管理后台-part1-权限与DTO.md) | 角色常量 / SecurityConfig片段 / 管理端 DTO & VO |
|
||||
| [10-管理后台-part2-Service.md](./10-管理后台-part2-Service.md) | AdminService 接口 + AdminServiceImpl(看板/用户/Skill/订单/积分规则) |
|
||||
| [10-管理后台-part3-Controller.md](./10-管理后台-part3-Controller.md) | AdminController + API 汇总表 |
|
||||
|
||||
---
|
||||
|
||||
## 通用基础设施
|
||||
|
||||
| 文件 | 说明 |
|
||||
@@ -70,16 +26,5 @@
|
||||
| [11-通用基础设施-part3-配置与工具类.md](./11-通用基础设施-part3-配置与工具类.md) | RedisConfig / MybatisPlusConfig / IdGenerator / pom.xml依赖 / application.yml完整示例 |
|
||||
|
||||
---
|
||||
|
||||
## 快速上手顺序
|
||||
|
||||
```
|
||||
1. 阅读 01-单体架构总体设计 → 理解整体结构
|
||||
2. 执行 02/03 数据库脚本 → 建表
|
||||
3. 配置 11-part3 的 application.yml
|
||||
4. 按模块顺序开发:用户 → Skill → 积分 → 订单 → 支付 → 邀请
|
||||
5. 最后接入 10-管理后台
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
**文档版本**:v2.0 | **更新日期**:2026-03-18
|
||||
**变更**:已清理 11 份过期的服务开发文档(04~10),代码已全部实现,请直接参阅源码。
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
# 用户服务开发文档
|
||||
|
||||
## 一、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;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,374 +0,0 @@
|
||||
# 用户服务开发文档 - Part 2(Controller + 通用工具)
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### UserController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.service.UserService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
/** 发送短信验证码(注册/找回密码用) */
|
||||
@PostMapping("/sms-code")
|
||||
public Result<Void> sendSmsCode(@RequestParam String phone) {
|
||||
userService.sendSmsCode(phone);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 用户注册 */
|
||||
@PostMapping("/register")
|
||||
public Result<LoginVO> register(@Valid @RequestBody UserRegisterDTO dto) {
|
||||
return Result.ok(userService.register(dto));
|
||||
}
|
||||
|
||||
/** 用户登录 */
|
||||
@PostMapping("/login")
|
||||
public Result<LoginVO> login(@Valid @RequestBody UserLoginDTO dto) {
|
||||
return Result.ok(userService.login(dto));
|
||||
}
|
||||
|
||||
/** 退出登录 */
|
||||
@PostMapping("/logout")
|
||||
public Result<Void> logout(@RequestHeader("Authorization") String authorization) {
|
||||
String token = authorization.replace("Bearer ", "");
|
||||
userService.logout(token);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
@GetMapping("/profile")
|
||||
public Result<UserVO> getProfile() {
|
||||
return Result.ok(userService.getCurrentUser(UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 更新个人信息 */
|
||||
@PutMapping("/profile")
|
||||
public Result<UserVO> updateProfile(@RequestBody UserUpdateDTO dto) {
|
||||
return Result.ok(userService.updateProfile(UserContext.getUserId(), dto));
|
||||
}
|
||||
|
||||
/** 修改密码 */
|
||||
@PutMapping("/password")
|
||||
public Result<Void> changePassword(
|
||||
@RequestParam String oldPassword,
|
||||
@RequestParam String newPassword) {
|
||||
userService.changePassword(UserContext.getUserId(), oldPassword, newPassword);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 忘记密码 - 重置 */
|
||||
@PostMapping("/password/reset")
|
||||
public Result<Void> resetPassword(
|
||||
@RequestParam String phone,
|
||||
@RequestParam String smsCode,
|
||||
@RequestParam String newPassword) {
|
||||
userService.resetPassword(phone, smsCode, newPassword);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 六、通用工具类
|
||||
|
||||
### Result.java
|
||||
|
||||
```java
|
||||
package com.openclaw.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class Result<T> {
|
||||
private Integer code;
|
||||
private String message;
|
||||
private T data;
|
||||
private Long timestamp = System.currentTimeMillis();
|
||||
|
||||
public static <T> Result<T> ok(T data) {
|
||||
Result<T> r = new Result<>();
|
||||
r.setCode(200);
|
||||
r.setMessage("success");
|
||||
r.setData(data);
|
||||
return r;
|
||||
}
|
||||
|
||||
public static <T> Result<T> ok() { return ok(null); }
|
||||
|
||||
public static <T> Result<T> error(int code, String message) {
|
||||
Result<T> r = new Result<>();
|
||||
r.setCode(code);
|
||||
r.setMessage(message);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ErrorCode.java
|
||||
|
||||
```java
|
||||
package com.openclaw.constant;
|
||||
|
||||
public interface ErrorCode {
|
||||
// 用户模块 1xxx
|
||||
BusinessError USER_NOT_FOUND = new BusinessError(1001, "用户不存在");
|
||||
BusinessError PASSWORD_ERROR = new BusinessError(1002, "密码错误");
|
||||
BusinessError PHONE_ALREADY_EXISTS = new BusinessError(1003, "手机号已注册");
|
||||
BusinessError USER_BANNED = new BusinessError(1004, "账号已封禁");
|
||||
BusinessError SMS_CODE_ERROR = new BusinessError(1005, "验证码错误或已过期");
|
||||
|
||||
// Skill模块 2xxx
|
||||
BusinessError SKILL_NOT_FOUND = new BusinessError(2001, "Skill不存在");
|
||||
BusinessError SKILL_OFFLINE = new BusinessError(2002, "Skill已下架");
|
||||
BusinessError SKILL_ALREADY_OWNED = new BusinessError(2003, "已拥有该Skill");
|
||||
|
||||
// 积分模块 3xxx
|
||||
BusinessError POINTS_NOT_ENOUGH = new BusinessError(3001, "积分不足");
|
||||
BusinessError ALREADY_SIGNED_IN = new BusinessError(3002, "今日已签到");
|
||||
|
||||
// 订单模块 4xxx
|
||||
BusinessError ORDER_NOT_FOUND = new BusinessError(4001, "订单不存在");
|
||||
BusinessError ORDER_STATUS_ERROR = new BusinessError(4002, "订单状态异常");
|
||||
|
||||
// 支付模块 5xxx
|
||||
BusinessError PAYMENT_FAILED = new BusinessError(5001, "支付失败");
|
||||
BusinessError RECHARGE_NOT_FOUND = new BusinessError(5002, "充值订单不存在");
|
||||
|
||||
record BusinessError(int code, String message) {}
|
||||
}
|
||||
```
|
||||
|
||||
### BusinessException.java
|
||||
|
||||
```java
|
||||
package com.openclaw.exception;
|
||||
|
||||
import com.openclaw.constant.ErrorCode.BusinessError;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
private final int code;
|
||||
|
||||
public BusinessException(BusinessError error) {
|
||||
super(error.message());
|
||||
this.code = error.code();
|
||||
}
|
||||
|
||||
public BusinessException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GlobalExceptionHandler.java
|
||||
|
||||
```java
|
||||
package com.openclaw.exception;
|
||||
|
||||
import com.openclaw.common.Result;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public Result<Void> handleBusiness(BusinessException e) {
|
||||
log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
|
||||
return Result.error(e.getCode(), e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(BindException.class)
|
||||
public Result<Void> handleValidation(BindException e) {
|
||||
String msg = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
|
||||
return Result.error(400, msg);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public Result<Void> handleException(Exception e) {
|
||||
log.error("系统异常", e);
|
||||
return Result.error(500, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JwtUtil.java
|
||||
|
||||
```java
|
||||
package com.openclaw.util;
|
||||
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
@Value("${jwt.secret}")
|
||||
private String secret;
|
||||
|
||||
@Value("${jwt.expiration:604800}")
|
||||
private long expiration; // 默认7天(秒)
|
||||
|
||||
private Key getKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes());
|
||||
}
|
||||
|
||||
public String generateToken(Long userId) {
|
||||
return Jwts.builder()
|
||||
.setSubject(String.valueOf(userId))
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
|
||||
.signWith(getKey(), SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Long getUserId(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getKey()).build()
|
||||
.parseClaimsJws(token).getBody();
|
||||
return Long.parseLong(claims.getSubject());
|
||||
}
|
||||
|
||||
public long getExpiration(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getKey()).build()
|
||||
.parseClaimsJws(token).getBody();
|
||||
long now = System.currentTimeMillis();
|
||||
return (claims.getExpiration().getTime() - now) / 1000;
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
try {
|
||||
Jwts.parserBuilder().setSigningKey(getKey()).build().parseClaimsJws(token);
|
||||
return true;
|
||||
} catch (JwtException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthInterceptor.java(JWT认证拦截器)
|
||||
|
||||
```java
|
||||
package com.openclaw.interceptor;
|
||||
|
||||
import com.openclaw.util.JwtUtil;
|
||||
import com.openclaw.util.UserContext;
|
||||
import jakarta.servlet.http.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception {
|
||||
String auth = req.getHeader("Authorization");
|
||||
if (!StringUtils.hasText(auth) || !auth.startsWith("Bearer ")) {
|
||||
res.setStatus(401);
|
||||
return false;
|
||||
}
|
||||
String token = auth.substring(7);
|
||||
// 检查 Token 是否在黑名单(已登出)
|
||||
if (Boolean.TRUE.equals(redisTemplate.hasKey("user:token:" + token))) {
|
||||
res.setStatus(401);
|
||||
return false;
|
||||
}
|
||||
if (!jwtUtil.isValid(token)) {
|
||||
res.setStatus(401);
|
||||
return false;
|
||||
}
|
||||
UserContext.setUserId(jwtUtil.getUserId(token));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object h, Exception ex) {
|
||||
UserContext.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UserContext.java
|
||||
|
||||
```java
|
||||
package com.openclaw.util;
|
||||
|
||||
public class UserContext {
|
||||
private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
|
||||
|
||||
public static void setUserId(Long userId) { USER_ID.set(userId); }
|
||||
public static Long getUserId() { return USER_ID.get(); }
|
||||
public static void clear() { USER_ID.remove(); }
|
||||
}
|
||||
```
|
||||
|
||||
### WebMvcConfig.java(注册拦截器)
|
||||
|
||||
```java
|
||||
package com.openclaw.config;
|
||||
|
||||
import com.openclaw.interceptor.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.*;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/v1/**")
|
||||
// 不需要登录的接口
|
||||
.excludePathPatterns(
|
||||
"/api/v1/users/sms-code",
|
||||
"/api/v1/users/register",
|
||||
"/api/v1/users/login",
|
||||
"/api/v1/users/password/reset",
|
||||
"/api/v1/skills", // Skill列表公开
|
||||
"/api/v1/skills/{id}", // Skill详情公开
|
||||
"/api/v1/skills/search", // 搜索公开
|
||||
"/api/v1/payments/callback" // 支付回调无token
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**创建日期**:2026-03-16
|
||||
@@ -1,432 +0,0 @@
|
||||
# Skill服务开发文档
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### Skill.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("skills")
|
||||
public class Skill {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long creatorId;
|
||||
private String name;
|
||||
private String description;
|
||||
private String coverImageUrl;
|
||||
private Integer categoryId;
|
||||
private BigDecimal price;
|
||||
private Boolean isFree;
|
||||
private String status; // draft/pending/approved/rejected/offline
|
||||
private String rejectReason;
|
||||
private Long auditorId; // 审核人ID
|
||||
private LocalDateTime auditedAt; // 审核时间
|
||||
private Integer downloadCount;
|
||||
private BigDecimal rating;
|
||||
private Integer ratingCount;
|
||||
private String version;
|
||||
private Long fileSize;
|
||||
private String fileUrl;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
@TableLogic
|
||||
private LocalDateTime deletedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### SkillCategory.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("skill_categories")
|
||||
public class SkillCategory {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Integer id;
|
||||
private String name;
|
||||
private Integer parentId;
|
||||
private String iconUrl;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
### SkillReview.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("skill_reviews")
|
||||
public class SkillReview {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long skillId;
|
||||
private Long userId;
|
||||
private Long orderId;
|
||||
private Integer rating;
|
||||
private String content;
|
||||
private String images; // JSON字符串
|
||||
private Integer helpfulCount;
|
||||
private String status; // pending/approved/rejected
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、DTO / VO
|
||||
|
||||
### SkillQueryDTO.java(列表查询参数)
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SkillQueryDTO {
|
||||
private Integer categoryId; // 分类筛选
|
||||
private String keyword; // 关键词搜索
|
||||
private Boolean isFree; // 是否免费
|
||||
private String sort; // newest/hottest/rating/price_asc/price_desc
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 10;
|
||||
}
|
||||
```
|
||||
|
||||
### SkillCreateDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class SkillCreateDTO {
|
||||
@NotBlank(message = "Skill名称不能为空")
|
||||
private String name;
|
||||
|
||||
private String description;
|
||||
private String coverImageUrl; // 腾讯云COS URL
|
||||
|
||||
@NotNull(message = "分类不能为空")
|
||||
private Integer categoryId;
|
||||
|
||||
private BigDecimal price = BigDecimal.ZERO;
|
||||
private Boolean isFree = false;
|
||||
private String version;
|
||||
private String fileUrl; // 腾讯云COS URL
|
||||
private Long fileSize;
|
||||
}
|
||||
```
|
||||
|
||||
### SkillReviewDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class SkillReviewDTO {
|
||||
@NotNull @Min(1) @Max(5)
|
||||
private Integer rating;
|
||||
|
||||
private String content;
|
||||
private List<String> images; // 腾讯云COS URL列表
|
||||
}
|
||||
```
|
||||
|
||||
### SkillVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class SkillVO {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String coverImageUrl;
|
||||
private Integer categoryId;
|
||||
private String categoryName;
|
||||
private BigDecimal price;
|
||||
private Boolean isFree;
|
||||
private Integer downloadCount;
|
||||
private BigDecimal rating;
|
||||
private Integer ratingCount;
|
||||
private String version;
|
||||
private Long fileSize;
|
||||
private String creatorNickname;
|
||||
private Boolean owned; // 当前用户是否已拥有
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、Service 接口
|
||||
|
||||
### SkillService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface SkillService {
|
||||
IPage<SkillVO> listSkills(SkillQueryDTO query, Long currentUserId);
|
||||
SkillVO getSkillDetail(Long skillId, Long currentUserId);
|
||||
SkillVO createSkill(Long userId, SkillCreateDTO dto);
|
||||
void submitReview(Long skillId, Long userId, SkillReviewDTO dto);
|
||||
boolean hasOwned(Long userId, Long skillId);
|
||||
void grantAccess(Long userId, Long skillId, Long orderId, String type);
|
||||
}
|
||||
```
|
||||
|
||||
## 四、Service 实现
|
||||
|
||||
### SkillServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
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.SkillService;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SkillServiceImpl implements SkillService {
|
||||
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillCategoryRepository categoryRepository;
|
||||
private final SkillReviewRepository reviewRepository;
|
||||
private final SkillDownloadRepository downloadRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Override
|
||||
public IPage<SkillVO> listSkills(SkillQueryDTO query, Long currentUserId) {
|
||||
Page<Skill> page = new Page<>(query.getPageNum(), query.getPageSize());
|
||||
LambdaQueryWrapper<Skill> wrapper = new LambdaQueryWrapper<>()
|
||||
.eq(Skill::getStatus, "approved")
|
||||
.eq(query.getCategoryId() != null, Skill::getCategoryId, query.getCategoryId())
|
||||
.eq(query.getIsFree() != null, Skill::getIsFree, query.getIsFree())
|
||||
.and(query.getKeyword() != null, w ->
|
||||
w.like(Skill::getName, query.getKeyword())
|
||||
.or().like(Skill::getDescription, query.getKeyword()));
|
||||
|
||||
// 排序
|
||||
switch (query.getSort() == null ? "newest" : query.getSort()) {
|
||||
case "hottest" -> wrapper.orderByDesc(Skill::getDownloadCount);
|
||||
case "rating" -> wrapper.orderByDesc(Skill::getRating);
|
||||
case "price_asc" -> wrapper.orderByAsc(Skill::getPrice);
|
||||
case "price_desc" -> wrapper.orderByDesc(Skill::getPrice);
|
||||
default -> wrapper.orderByDesc(Skill::getCreatedAt);
|
||||
}
|
||||
|
||||
IPage<Skill> result = skillRepository.selectPage(page, wrapper);
|
||||
return result.convert(skill -> toVO(skill, currentUserId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public SkillVO getSkillDetail(Long skillId, Long currentUserId) {
|
||||
Skill skill = skillRepository.selectById(skillId);
|
||||
if (skill == null || "offline".equals(skill.getStatus()))
|
||||
throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
return toVO(skill, currentUserId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public SkillVO createSkill(Long userId, SkillCreateDTO dto) {
|
||||
Skill skill = new Skill();
|
||||
skill.setCreatorId(userId);
|
||||
skill.setName(dto.getName());
|
||||
skill.setDescription(dto.getDescription());
|
||||
skill.setCoverImageUrl(dto.getCoverImageUrl());
|
||||
skill.setCategoryId(dto.getCategoryId());
|
||||
skill.setPrice(dto.getPrice());
|
||||
skill.setIsFree(dto.getIsFree());
|
||||
skill.setVersion(dto.getVersion());
|
||||
skill.setFileUrl(dto.getFileUrl());
|
||||
skill.setFileSize(dto.getFileSize());
|
||||
skill.setStatus("pending"); // 提交审核
|
||||
skill.setDownloadCount(0);
|
||||
skillRepository.insert(skill);
|
||||
return toVO(skill, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void submitReview(Long skillId, Long userId, SkillReviewDTO dto) {
|
||||
// 检查是否已购买
|
||||
if (!hasOwned(userId, skillId)) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
|
||||
SkillReview review = new SkillReview();
|
||||
review.setSkillId(skillId);
|
||||
review.setUserId(userId);
|
||||
review.setRating(dto.getRating());
|
||||
review.setContent(dto.getContent());
|
||||
if (dto.getImages() != null) {
|
||||
review.setImages(dto.getImages().toString());
|
||||
}
|
||||
review.setStatus("approved");
|
||||
reviewRepository.insert(review);
|
||||
|
||||
// 更新Skill平均评分
|
||||
updateSkillRating(skillId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasOwned(Long userId, Long skillId) {
|
||||
if (userId == null) return false;
|
||||
return downloadRepository.selectCount(
|
||||
new LambdaQueryWrapper<SkillDownload>()
|
||||
.eq(SkillDownload::getUserId, userId)
|
||||
.eq(SkillDownload::getSkillId, skillId)) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void grantAccess(Long userId, Long skillId, Long orderId, String type) {
|
||||
SkillDownload d = new SkillDownload();
|
||||
d.setUserId(userId);
|
||||
d.setSkillId(skillId);
|
||||
d.setOrderId(orderId);
|
||||
d.setDownloadType(type);
|
||||
downloadRepository.insert(d);
|
||||
// 更新下载次数
|
||||
skillRepository.incrementDownloadCount(skillId);
|
||||
}
|
||||
|
||||
private void updateSkillRating(Long skillId) {
|
||||
// 重新计算平均分
|
||||
Double avg = reviewRepository.avgRatingBySkillId(skillId);
|
||||
Integer cnt = reviewRepository.countBySkillId(skillId);
|
||||
if (avg != null) {
|
||||
skillRepository.updateRating(skillId,
|
||||
java.math.BigDecimal.valueOf(avg).setScale(2, java.math.RoundingMode.HALF_UP), cnt);
|
||||
}
|
||||
}
|
||||
|
||||
private SkillVO toVO(Skill skill, Long currentUserId) {
|
||||
SkillVO vo = new SkillVO();
|
||||
vo.setId(skill.getId());
|
||||
vo.setName(skill.getName());
|
||||
vo.setDescription(skill.getDescription());
|
||||
vo.setCoverImageUrl(skill.getCoverImageUrl());
|
||||
vo.setCategoryId(skill.getCategoryId());
|
||||
vo.setPrice(skill.getPrice());
|
||||
vo.setIsFree(skill.getIsFree());
|
||||
vo.setDownloadCount(skill.getDownloadCount());
|
||||
vo.setRating(skill.getRating());
|
||||
vo.setRatingCount(skill.getRatingCount());
|
||||
vo.setVersion(skill.getVersion());
|
||||
vo.setFileSize(skill.getFileSize());
|
||||
vo.setCreatedAt(skill.getCreatedAt());
|
||||
// 分类名
|
||||
SkillCategory cat = categoryRepository.selectById(skill.getCategoryId());
|
||||
if (cat != null) vo.setCategoryName(cat.getName());
|
||||
// 创建者昵称
|
||||
User creator = userRepository.selectById(skill.getCreatorId());
|
||||
if (creator != null) vo.setCreatorNickname(creator.getNickname());
|
||||
// 是否已拥有
|
||||
vo.setOwned(hasOwned(currentUserId, skill.getId()));
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### SkillController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.service.SkillService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skills")
|
||||
@RequiredArgsConstructor
|
||||
public class SkillController {
|
||||
|
||||
private final SkillService skillService;
|
||||
|
||||
/** Skill列表(公开,支持分页/筛选/排序) */
|
||||
@GetMapping
|
||||
public Result<IPage<SkillVO>> listSkills(SkillQueryDTO query) {
|
||||
Long userId = UserContext.getUserId(); // 未登录为null
|
||||
return Result.ok(skillService.listSkills(query, userId));
|
||||
}
|
||||
|
||||
/** Skill详情(公开) */
|
||||
@GetMapping("/{id}")
|
||||
public Result<SkillVO> getDetail(@PathVariable Long id) {
|
||||
return Result.ok(skillService.getSkillDetail(id, UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 上传Skill(需登录) */
|
||||
@PostMapping
|
||||
public Result<SkillVO> createSkill(@Valid @RequestBody SkillCreateDTO dto) {
|
||||
return Result.ok(skillService.createSkill(UserContext.getUserId(), dto));
|
||||
}
|
||||
|
||||
/** 发表评价(需登录且已拥有) */
|
||||
@PostMapping("/{id}/reviews")
|
||||
public Result<Void> submitReview(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody SkillReviewDTO dto) {
|
||||
skillService.submitReview(id, UserContext.getUserId(), dto);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**创建日期**:2026-03-16
|
||||
251
后端架构设计/05-积分冻结与过期机制-未实现功能设计.md
Normal file
251
后端架构设计/05-积分冻结与过期机制-未实现功能设计.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# 积分冻结与过期机制 — 未实现功能设计
|
||||
|
||||
> 本文档仅覆盖**尚未实现的功能**中需要用到积分冻结/过期机制的场景。
|
||||
> 已实现功能(订单冻结、活动冻结、批次过期等)不在本文档范围内。
|
||||
> 编写日期:2026-03-21
|
||||
|
||||
---
|
||||
|
||||
## 一、积分转赠(待实现功能 #26)
|
||||
|
||||
### 1.1 冻结机制
|
||||
|
||||
| 环节 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 发起转赠 | `freezePoints(senderId, amount, transferId)` | A 发起转赠→冻结 A 的积分 |
|
||||
| 对方接收 | `consumeFrozenPoints(senderId, amount, transferId)` + `earnPoints(receiverId, ...)` | B 确认→扣除 A 冻结积分,发放给 B |
|
||||
| 对方拒绝/超时 | `unfreezePoints(senderId, amount, transferId)` | 超过24小时未接收→自动解冻退回 A |
|
||||
|
||||
### 1.2 过期机制
|
||||
|
||||
- B 收到的转赠积分创建新批次,有效期建议 **90天**(防止积分通过转赠无限续期)
|
||||
- 转赠积分的有效期不应继承原批次剩余有效期(否则可通过互转刷新有效期)
|
||||
|
||||
### 1.3 风控要点
|
||||
|
||||
- 单日转赠上限:每人每日最多转出 500 积分
|
||||
- 手续费:转赠扣除 10% 手续费(A 转 100,B 收到 90)
|
||||
- 转赠积分不可再次转赠(标记 `source = 'transfer_in'`,转赠时校验批次来源)
|
||||
|
||||
### 1.4 数据库设计建议
|
||||
|
||||
```sql
|
||||
CREATE TABLE points_transfers (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
sender_id BIGINT NOT NULL COMMENT '转出方用户ID',
|
||||
receiver_id BIGINT NOT NULL COMMENT '接收方用户ID',
|
||||
amount INT NOT NULL COMMENT '转赠积分(扣除手续费前)',
|
||||
fee INT NOT NULL DEFAULT 0 COMMENT '手续费积分',
|
||||
actual_amount INT NOT NULL COMMENT '实际到账积分',
|
||||
status ENUM('pending', 'accepted', 'rejected', 'expired', 'cancelled') DEFAULT 'pending',
|
||||
expired_at DATETIME NOT NULL COMMENT '接收截止时间(24小时)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_sender (sender_id),
|
||||
INDEX idx_receiver (receiver_id),
|
||||
INDEX idx_status (status)
|
||||
) COMMENT='积分转赠记录表';
|
||||
```
|
||||
|
||||
### 1.5 接口设计
|
||||
|
||||
```
|
||||
POST /api/v1/points/transfer 发起转赠(冻结积分)
|
||||
POST /api/v1/points/transfer/{id}/accept 接收转赠(消费冻结+发放)
|
||||
POST /api/v1/points/transfer/{id}/reject 拒绝转赠(解冻退回)
|
||||
GET /api/v1/points/transfers 我的转赠记录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、优惠券系统(待实现功能 #16)
|
||||
|
||||
### 2.1 冻结机制 — 积分兑换优惠券
|
||||
|
||||
| 环节 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 兑换优惠券 | `freezePoints(userId, amount, couponOrderId)` | 用户用积分兑换优惠券→先冻结 |
|
||||
| 兑换确认 | `consumeFrozenPoints(userId, amount, couponOrderId)` | 系统发放优惠券成功→消费冻结积分 |
|
||||
| 兑换失败 | `unfreezePoints(userId, amount, couponOrderId)` | 优惠券库存不足或系统异常→解冻退回 |
|
||||
|
||||
### 2.2 过期机制 — 优惠券与积分联动
|
||||
|
||||
- 优惠券本身有独立有效期,与积分过期无直接关系
|
||||
- 但如果优惠券过期未使用且支持退还积分,退还的积分建议有效期 **30天**(短周期促消费)
|
||||
|
||||
### 2.3 风控要点
|
||||
|
||||
- 同一优惠券每人限兑 1 张(防囤券)
|
||||
- 兑换冻结窗口 ≤ 5秒(快速确认,减少积分被长时间锁定)
|
||||
|
||||
---
|
||||
|
||||
## 三、限时折扣/秒杀活动(待实现功能 #17)
|
||||
|
||||
### 3.1 冻结机制 — 积分参与秒杀
|
||||
|
||||
| 环节 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 参与秒杀 | `freezeForActivity(userId, amount, activityId, title)` | 用户参与积分秒杀→冻结积分 |
|
||||
| 秒杀成功 | `consumeFrozenForActivity(userId, amount, activityId, title)` | 中标→消费冻结积分,发放 Skill |
|
||||
| 秒杀失败 | `unfreezeForActivity(userId, amount, activityId, title)` | 未中→解冻退回 |
|
||||
|
||||
### 3.2 过期机制 — 活动赠送积分
|
||||
|
||||
- 限时活动赠送的积分建议有效期 **7-15天**(制造紧迫感,推动快速消费)
|
||||
- 活动积分批次 `source = 'activity'`,定时过期任务统一处理
|
||||
|
||||
### 3.3 关键设计
|
||||
|
||||
- 秒杀冻结必须原子操作(Redis + 乐观锁防超卖)
|
||||
- 活动结束后批量解冻所有未中标用户的冻结积分(定时任务 / 活动结束回调)
|
||||
|
||||
---
|
||||
|
||||
## 四、安全风控体系(待实现功能 #25)
|
||||
|
||||
### 4.1 冻结机制 — 异常行为冻结
|
||||
|
||||
| 场景 | 冻结操作 | 说明 |
|
||||
|------|---------|------|
|
||||
| 刷积分检测 | 冻结全部可用积分 | 检测到短时间大量签到/邀请奖励异常→冻结账户积分 |
|
||||
| 恶意退款检测 | 冻结退还积分 | 频繁购买-退款套利→冻结退还的积分待人工审核 |
|
||||
| 多账号关联 | 冻结关联账号积分 | 检测到同设备/IP多账号互相邀请→冻结所有关联账号 |
|
||||
|
||||
### 4.2 实现方式
|
||||
|
||||
```java
|
||||
// 风控冻结:与业务冻结区分,使用独立的 source 标记
|
||||
void freezeByRisk(Long userId, int amount, String riskType, String reason);
|
||||
void unfreezeByRisk(Long userId, int amount, Long auditId); // 管理员审核后解冻
|
||||
```
|
||||
|
||||
### 4.3 数据库设计建议
|
||||
|
||||
```sql
|
||||
CREATE TABLE risk_freeze_records (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
freeze_amount INT NOT NULL COMMENT '冻结积分数',
|
||||
risk_type VARCHAR(50) NOT NULL COMMENT '风险类型: point_abuse/refund_abuse/multi_account',
|
||||
reason VARCHAR(500) COMMENT '冻结原因',
|
||||
status ENUM('frozen', 'unfrozen', 'deducted') DEFAULT 'frozen',
|
||||
auditor_id BIGINT COMMENT '审核人ID',
|
||||
audit_note VARCHAR(500) COMMENT '审核备注',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at DATETIME COMMENT '处理时间',
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_status (status)
|
||||
) COMMENT='风控冻结记录表';
|
||||
```
|
||||
|
||||
### 4.4 风控规则示例
|
||||
|
||||
| 规则 | 触发条件 | 冻结策略 |
|
||||
|------|---------|---------|
|
||||
| 签到异常 | 同一设备 ≥3 个账号当天签到 | 冻结所有关联账号当天签到积分 |
|
||||
| 邀请刷量 | 24小时内邀请 ≥10 人且被邀请人无后续活跃 | 冻结邀请奖励积分 |
|
||||
| 退款套利 | 30天内退款 ≥3 次 | 冻结账户全部积分,人工审核 |
|
||||
|
||||
---
|
||||
|
||||
## 五、积分规则后台配置(待实现功能 #30)
|
||||
|
||||
### 5.1 过期机制 — 差异化有效期配置
|
||||
|
||||
当前 `DEFAULT_EXPIRE_DAYS = 365` 是硬编码。需要改为后台可配置:
|
||||
|
||||
```sql
|
||||
-- 在 points_rules 表新增字段
|
||||
ALTER TABLE points_rules ADD COLUMN expire_days INT DEFAULT 365 COMMENT '积分有效期(天),0=永不过期';
|
||||
```
|
||||
|
||||
### 5.2 配置示例
|
||||
|
||||
| 积分来源 | 建议有效期 | 配置字段 |
|
||||
|---------|-----------|---------|
|
||||
| register | 30天 | `expire_days = 30` |
|
||||
| sign_in | 90天 | `expire_days = 90` |
|
||||
| invite | 180天 | `expire_days = 180` |
|
||||
| recharge | 365天 | `expire_days = 365` |
|
||||
| activity | 7-15天 | `expire_days = 7` (按活动单独配置) |
|
||||
| review | 90天 | `expire_days = 90` |
|
||||
| admin_add | 0(永不过期) | `expire_days = 0` |
|
||||
| refund | 180天 | `expire_days = 180` |
|
||||
| transfer_in | 90天 | `expire_days = 90` |
|
||||
|
||||
### 5.3 代码改造要点
|
||||
|
||||
```java
|
||||
// PointsServiceImpl.createBatch() 改为从 rules 表读取 expire_days
|
||||
private void createBatch(Long userId, String source, int amount, Long relatedId, String relatedType) {
|
||||
int expireDays = getExpireDaysBySource(source); // 从 points_rules 表查询
|
||||
LocalDateTime expireAt = expireDays > 0
|
||||
? LocalDateTime.now().plusDays(expireDays)
|
||||
: LocalDateTime.of(2099, 12, 31, 23, 59, 59); // 0=永不过期
|
||||
// ... 创建批次
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、在线客服/工单系统(待实现功能 #22)
|
||||
|
||||
### 6.1 冻结机制 — 争议工单积分冻结
|
||||
|
||||
| 场景 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 用户提交争议工单 | 可选冻结争议订单关联积分 | 涉及积分纠纷的工单,客服可手动冻结相关积分 |
|
||||
| 工单解决 | 解冻或扣除 | 根据裁决结果解冻退回或扣除 |
|
||||
|
||||
### 6.2 实现方式
|
||||
|
||||
- 客服后台增加"冻结用户积分"操作按钮
|
||||
- 记录操作日志(关联工单ID),审计可追溯
|
||||
- 解冻需要高级客服或管理员权限
|
||||
|
||||
---
|
||||
|
||||
## 七、实现优先级建议
|
||||
|
||||
| 优先级 | 功能 | 冻结/过期关键点 | 依赖 |
|
||||
|--------|------|----------------|------|
|
||||
| **P1** | #30 积分规则后台配置 | 差异化有效期是所有过期策略的基础 | 无 |
|
||||
| **P1** | #25 风控体系 | 风控冻结是防刷核心手段 | 无 |
|
||||
| **P2** | #26 积分转赠 | 冻结防并发+过期防续期 | 无 |
|
||||
| **P2** | #16 优惠券系统 | 兑换冻结+退还过期 | 优惠券模块 |
|
||||
| **P2** | #17 秒杀活动 | 参与冻结+活动积分短过期 | 活动模块增强 |
|
||||
| **P3** | #22 客服工单 | 争议冻结 | 客服系统 |
|
||||
|
||||
---
|
||||
|
||||
## 八、通用技术要点
|
||||
|
||||
### 8.1 冻结与解冻的幂等性
|
||||
|
||||
所有冻结/解冻操作必须带业务ID(orderId/transferId/activityId),防止重复冻结:
|
||||
|
||||
```java
|
||||
// 冻结前检查是否已冻结
|
||||
boolean alreadyFrozen = recordRepo.exists(
|
||||
new LambdaQueryWrapper<PointsRecord>()
|
||||
.eq(PointsRecord::getRelatedId, bizId)
|
||||
.eq(PointsRecord::getRelatedType, bizType)
|
||||
.eq(PointsRecord::getPointsType, "freeze")
|
||||
);
|
||||
if (alreadyFrozen) return; // 幂等,跳过
|
||||
```
|
||||
|
||||
### 8.2 过期批次的FIFO消费
|
||||
|
||||
消费积分时优先扣最早到期的批次(已实现 `consumeBatchesFIFO`),确保:
|
||||
- 用户总是先用"快过期"的积分
|
||||
- 减少过期浪费,提升用户体验
|
||||
|
||||
### 8.3 冻结积分不参与过期
|
||||
|
||||
被冻结的积分在冻结期间不应触发过期(过期定时任务需排除冻结状态的批次)。如果业务解冻后发现原批次已过期,应创建新批次并设合理有效期。
|
||||
|
||||
---
|
||||
|
||||
*本文档随功能开发进度持续更新*
|
||||
675
后端架构设计/06-优惠券与活动系统设计.md
Normal file
675
后端架构设计/06-优惠券与活动系统设计.md
Normal file
@@ -0,0 +1,675 @@
|
||||
# 优惠券系统 & 活动系统 — 前后端开发方案
|
||||
|
||||
> 基于现有项目架构:Java Spring Boot + MyBatis-Plus + RabbitMQ + Redis / Vue 3 + Ant Design Vue
|
||||
> 创建日期:2026-03-21
|
||||
|
||||
---
|
||||
|
||||
## 一、总体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 用户端 (Vue 3) │
|
||||
│ 领券中心 │ Skill详情(券选择) │ 下单(券抵扣) │ 活动专区 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 后端服务 (Spring Boot) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ coupon 模块 │ promotion 模块 │
|
||||
│ ├ CouponTemplate │ ├ PromotionActivity │
|
||||
│ ├ UserCoupon │ ├ PromotionSkill │
|
||||
│ ├ CouponService │ ├ FlashSaleSession │
|
||||
│ └ CouponController │ ├ PromotionService │
|
||||
│ │ └ PromotionController │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ order 模块(改造) │
|
||||
│ ├ previewOrder → 增加优惠券/活动价计算 │
|
||||
│ ├ createOrder → 增加券核销 + 活动价锁定 │
|
||||
│ └ cancelOrder → 增加券退回 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MySQL (coupon_templates / user_coupons / │
|
||||
│ promotion_activities / promotion_skills / │
|
||||
│ flash_sale_sessions) │
|
||||
│ Redis (库存预扣 / 防刷 / 活动缓存) │
|
||||
│ RabbitMQ (券过期 / 活动开始结束 / 秒杀异步下单) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、优惠券系统
|
||||
|
||||
### 2.1 数据库设计
|
||||
|
||||
#### coupon_templates — 优惠券模板(管理员创建)
|
||||
|
||||
```sql
|
||||
CREATE TABLE coupon_templates (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL COMMENT '券名称',
|
||||
coupon_type ENUM('full_reduce','discount','fixed') NOT NULL COMMENT '满减/折扣/立减',
|
||||
|
||||
-- 优惠规则(JSON,灵活扩展)
|
||||
threshold_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '使用门槛金额(0=无门槛)',
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '满减/立减金额',
|
||||
discount_rate DECIMAL(3,2) DEFAULT 1.00 COMMENT '折扣率(0.80=8折)',
|
||||
max_discount_amount DECIMAL(10,2) COMMENT '折扣券最大优惠金额(封顶)',
|
||||
|
||||
-- 适用范围
|
||||
scope_type ENUM('all','category','skill') DEFAULT 'all' COMMENT '全场/指定分类/指定Skill',
|
||||
scope_ids JSON COMMENT '适用的分类ID或SkillID数组',
|
||||
|
||||
-- 发放规则
|
||||
total_count INT NOT NULL COMMENT '发行总量(-1=不限)',
|
||||
issued_count INT DEFAULT 0 COMMENT '已发放数量',
|
||||
per_user_limit INT DEFAULT 1 COMMENT '每人限领张数',
|
||||
|
||||
-- 时间
|
||||
valid_type ENUM('fixed','relative') DEFAULT 'fixed' COMMENT '固定时段/领取后N天',
|
||||
valid_start DATETIME COMMENT 'fixed模式: 生效开始时间',
|
||||
valid_end DATETIME COMMENT 'fixed模式: 生效结束时间',
|
||||
valid_days INT COMMENT 'relative模式: 领取后N天有效',
|
||||
|
||||
-- 状态
|
||||
status ENUM('draft','active','paused','expired','exhausted') DEFAULT 'draft',
|
||||
description VARCHAR(500) COMMENT '使用说明',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_coupon_type (coupon_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='优惠券模板表';
|
||||
```
|
||||
|
||||
#### user_coupons — 用户持有的优惠券
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_coupons (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
template_id BIGINT NOT NULL COMMENT '券模板ID',
|
||||
coupon_code VARCHAR(32) NOT NULL UNIQUE COMMENT '券码(唯一)',
|
||||
|
||||
status ENUM('unused','used','expired','returned') DEFAULT 'unused',
|
||||
|
||||
-- 冗余快照(下单时用,避免回查模板)
|
||||
coupon_type VARCHAR(20) NOT NULL,
|
||||
threshold_amount DECIMAL(10,2) DEFAULT 0.00,
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0.00,
|
||||
discount_rate DECIMAL(3,2) DEFAULT 1.00,
|
||||
max_discount_amount DECIMAL(10,2),
|
||||
scope_type VARCHAR(20) DEFAULT 'all',
|
||||
scope_ids JSON,
|
||||
|
||||
-- 有效期(计算后的绝对时间)
|
||||
valid_start DATETIME NOT NULL,
|
||||
valid_end DATETIME NOT NULL,
|
||||
|
||||
-- 使用记录
|
||||
used_order_id BIGINT COMMENT '使用的订单ID',
|
||||
used_at DATETIME COMMENT '使用时间',
|
||||
|
||||
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '领取时间',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_user_status (user_id, status),
|
||||
INDEX idx_template_id (template_id),
|
||||
INDEX idx_valid_end (valid_end),
|
||||
INDEX idx_coupon_code (coupon_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户优惠券表';
|
||||
```
|
||||
|
||||
### 2.2 后端模块设计
|
||||
|
||||
```
|
||||
module/coupon/
|
||||
├── controller/
|
||||
│ ├── CouponController.java # 用户端: 领券/我的券/可用券查询
|
||||
│ └── AdminCouponController.java # 管理端: 券模板CRUD/发放/统计
|
||||
├── dto/
|
||||
│ ├── CouponTemplateCreateDTO.java # 创建券模板
|
||||
│ ├── CouponTemplateUpdateDTO.java # 更新券模板
|
||||
│ └── CouponIssueDTO.java # 手动发券(指定用户)
|
||||
├── entity/
|
||||
│ ├── CouponTemplate.java
|
||||
│ └── UserCoupon.java
|
||||
├── repository/
|
||||
│ ├── CouponTemplateRepository.java
|
||||
│ └── UserCouponRepository.java
|
||||
├── service/
|
||||
│ ├── CouponService.java
|
||||
│ └── impl/CouponServiceImpl.java
|
||||
├── vo/
|
||||
│ ├── CouponTemplateVO.java
|
||||
│ ├── UserCouponVO.java
|
||||
│ └── CouponCalcResultVO.java # 优惠计算结果
|
||||
└── task/
|
||||
└── CouponExpireTask.java # 定时过期未使用券
|
||||
```
|
||||
|
||||
### 2.3 核心 API 设计
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/coupons/available` | 可领取的券列表 | 登录 |
|
||||
| POST | `/api/coupons/{templateId}/receive` | 用户领券 | 登录 |
|
||||
| GET | `/api/coupons/my` | 我的优惠券(分tab: unused/used/expired) | 登录 |
|
||||
| GET | `/api/coupons/usable?skillIds=1,2` | 下单时可用的券(传入SkillID,自动匹配) | 登录 |
|
||||
| POST | `/api/coupons/calc` | 计算优惠金额(预览) | 登录 |
|
||||
| — | — | **管理端** | — |
|
||||
| POST | `/api/admin/coupons/templates` | 创建券模板 | ADMIN |
|
||||
| PUT | `/api/admin/coupons/templates/{id}` | 编辑券模板 | ADMIN |
|
||||
| PUT | `/api/admin/coupons/templates/{id}/status` | 上架/暂停/下架 | ADMIN |
|
||||
| GET | `/api/admin/coupons/templates` | 券模板列表(分页+筛选) | ADMIN |
|
||||
| POST | `/api/admin/coupons/issue` | 手动发券给指定用户 | ADMIN |
|
||||
| GET | `/api/admin/coupons/stats/{templateId}` | 单券统计(发放/使用/过期) | ADMIN |
|
||||
|
||||
### 2.4 核心业务逻辑
|
||||
|
||||
#### 领券流程
|
||||
```
|
||||
1. 校验券模板状态(active) + 时间有效 + 库存充足
|
||||
2. Redis 原子扣减库存: DECR coupon:stock:{templateId}(防超发)
|
||||
3. 校验用户领取上限: COUNT user_coupons WHERE user_id AND template_id
|
||||
4. 生成券码(UUID短码): 插入 user_coupons(快照券规则)
|
||||
5. DB 更新 issued_count(最终一致,Redis为准)
|
||||
6. 若DB插入失败 → Redis INCR 回补库存
|
||||
```
|
||||
|
||||
#### 下单优惠计算(改造现有 OrderServiceImpl.previewOrder)
|
||||
```
|
||||
1. 原有逻辑:计算 totalAmount / 积分抵扣
|
||||
2. 新增步骤:
|
||||
a. 若传入 couponId → 查 user_coupons 校验(unused + 未过期 + 适用范围匹配)
|
||||
b. 根据 coupon_type 计算优惠:
|
||||
- full_reduce: totalAmount >= threshold → 减 discount_amount
|
||||
- discount: totalAmount * discount_rate,不超过 max_discount_amount
|
||||
- fixed: 直接减 discount_amount
|
||||
c. couponDeductAmount = min(优惠金额, totalAmount)
|
||||
d. 优惠券抵扣在积分抵扣之前: finalAmount = totalAmount - couponDeduct
|
||||
e. 再对 finalAmount 计算积分抵扣
|
||||
```
|
||||
|
||||
#### 下单时核销券
|
||||
```
|
||||
1. createOrder 增加 couponId 参数
|
||||
2. CAS 更新 user_coupons SET status='used', used_order_id=?, used_at=NOW()
|
||||
WHERE id=? AND status='unused' AND valid_end > NOW()
|
||||
3. 若 rows=0 → 抛异常"优惠券已使用或已过期"
|
||||
4. orders 表新增字段:
|
||||
- coupon_id BIGINT COMMENT '使用的优惠券ID'
|
||||
- coupon_deduct_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '优惠券抵扣金额'
|
||||
```
|
||||
|
||||
#### 取消/退款时退券
|
||||
```
|
||||
cancelOrder / approveRefund 中:
|
||||
IF order.coupon_id IS NOT NULL:
|
||||
UPDATE user_coupons SET status='returned', used_order_id=NULL, used_at=NULL
|
||||
WHERE id=couponId AND status='used'
|
||||
— 注: returned状态的券不可再次使用(防止薅羊毛循环退券重用)
|
||||
— 也可按业务需求改为退回unused状态(需评估风险)
|
||||
```
|
||||
|
||||
#### 定时过期
|
||||
```
|
||||
CouponExpireTask (@Scheduled cron="0 0 2 * * ?"):
|
||||
UPDATE user_coupons SET status='expired'
|
||||
WHERE status='unused' AND valid_end < NOW()
|
||||
— 分批执行,每批1000条,防止大事务
|
||||
```
|
||||
|
||||
### 2.5 Redis Key 设计
|
||||
|
||||
| Key | 说明 | TTL |
|
||||
|-----|------|-----|
|
||||
| `coupon:stock:{templateId}` | 券库存(原子扣减防超发) | 券过期后清理 |
|
||||
| `coupon:receive:lock:{userId}:{templateId}` | 领券防重复(分布式锁) | 5秒 |
|
||||
| `coupon:user:count:{userId}:{templateId}` | 用户已领数量缓存 | 1小时 |
|
||||
|
||||
---
|
||||
|
||||
## 三、活动系统(限时折扣 / 秒杀)
|
||||
|
||||
### 3.1 说明
|
||||
|
||||
现有 `activity` 模块是**内容运营型活动**(轮播展示、跳转链接),不包含价格逻辑。
|
||||
本次新建 `promotion` 模块,专注**营销促销**:限时折扣、满减活动、秒杀。
|
||||
|
||||
### 3.2 数据库设计
|
||||
|
||||
#### promotion_activities — 促销活动主表
|
||||
|
||||
```sql
|
||||
CREATE TABLE promotion_activities (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(200) NOT NULL COMMENT '活动名称',
|
||||
promo_type ENUM('time_discount','flash_sale','full_reduce') NOT NULL
|
||||
COMMENT '限时折扣/秒杀/满减活动',
|
||||
|
||||
-- 活动规则
|
||||
discount_rate DECIMAL(3,2) COMMENT '限时折扣率(0.70=7折)',
|
||||
threshold_amount DECIMAL(10,2) COMMENT '满减门槛',
|
||||
reduce_amount DECIMAL(10,2) COMMENT '满减金额',
|
||||
|
||||
-- 时间
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
|
||||
-- 状态
|
||||
status ENUM('draft','pending','active','ended','cancelled') DEFAULT 'draft'
|
||||
COMMENT 'draft=草稿 pending=待开始 active=进行中 ended=已结束',
|
||||
|
||||
-- 展示
|
||||
cover_image VARCHAR(500) COMMENT '活动封面图',
|
||||
description TEXT COMMENT '活动描述',
|
||||
banner_url VARCHAR(500) COMMENT '活动Banner图',
|
||||
sort_order INT DEFAULT 0,
|
||||
|
||||
-- 限制
|
||||
user_limit INT DEFAULT 0 COMMENT '每用户限购次数(0=不限)',
|
||||
|
||||
created_by BIGINT COMMENT '创建管理员ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_promo_type (promo_type),
|
||||
INDEX idx_time (start_time, end_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='促销活动表';
|
||||
```
|
||||
|
||||
#### promotion_skills — 活动关联Skill(活动价 / 库存)
|
||||
|
||||
```sql
|
||||
CREATE TABLE promotion_skills (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
activity_id BIGINT NOT NULL,
|
||||
skill_id BIGINT NOT NULL,
|
||||
|
||||
original_price DECIMAL(10,2) NOT NULL COMMENT '原价快照',
|
||||
promo_price DECIMAL(10,2) NOT NULL COMMENT '活动价',
|
||||
|
||||
-- 库存(秒杀/限量用)
|
||||
total_stock INT DEFAULT -1 COMMENT '活动库存(-1=不限)',
|
||||
sold_count INT DEFAULT 0 COMMENT '已售数量',
|
||||
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (activity_id) REFERENCES promotion_activities(id),
|
||||
UNIQUE KEY uk_activity_skill (activity_id, skill_id),
|
||||
INDEX idx_skill_id (skill_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动Skill关联表';
|
||||
```
|
||||
|
||||
#### flash_sale_sessions — 秒杀场次(可选,秒杀专用)
|
||||
|
||||
```sql
|
||||
CREATE TABLE flash_sale_sessions (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
activity_id BIGINT NOT NULL,
|
||||
session_name VARCHAR(100) COMMENT '场次名(如: 10点场/14点场)',
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
status ENUM('pending','active','ended') DEFAULT 'pending',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (activity_id) REFERENCES promotion_activities(id),
|
||||
INDEX idx_activity_id (activity_id),
|
||||
INDEX idx_time (start_time, end_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='秒杀场次表';
|
||||
```
|
||||
|
||||
### 3.3 后端模块设计
|
||||
|
||||
```
|
||||
module/promotion/
|
||||
├── controller/
|
||||
│ ├── PromotionController.java # 用户端: 活动列表/详情/活动价查询
|
||||
│ └── AdminPromotionController.java # 管理端: 活动CRUD/关联Skill/统计
|
||||
├── dto/
|
||||
│ ├── PromotionCreateDTO.java
|
||||
│ ├── PromotionUpdateDTO.java
|
||||
│ └── PromotionSkillDTO.java # 添加活动Skill
|
||||
├── entity/
|
||||
│ ├── PromotionActivity.java
|
||||
│ ├── PromotionSkill.java
|
||||
│ └── FlashSaleSession.java
|
||||
├── repository/
|
||||
│ ├── PromotionActivityRepository.java
|
||||
│ ├── PromotionSkillRepository.java
|
||||
│ └── FlashSaleSessionRepository.java
|
||||
├── service/
|
||||
│ ├── PromotionService.java
|
||||
│ └── impl/PromotionServiceImpl.java
|
||||
├── vo/
|
||||
│ ├── PromotionActivityVO.java
|
||||
│ ├── PromotionSkillVO.java
|
||||
│ └── FlashSaleVO.java
|
||||
└── task/
|
||||
├── PromotionStatusTask.java # 定时更新活动状态(pending→active→ended)
|
||||
└── FlashSaleWarmUpTask.java # 秒杀预热: 库存加载到Redis
|
||||
```
|
||||
|
||||
### 3.4 核心 API 设计
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/promotions` | 进行中的活动列表 | 公开 |
|
||||
| GET | `/api/promotions/{id}` | 活动详情(含活动Skill列表+活动价) | 公开 |
|
||||
| GET | `/api/promotions/skill/{skillId}` | 查询某Skill当前参与的活动(有则返回活动价) | 公开 |
|
||||
| GET | `/api/promotions/flash-sales` | 秒杀场次列表(含倒计时) | 公开 |
|
||||
| — | — | **管理端** | — |
|
||||
| POST | `/api/admin/promotions` | 创建促销活动 | ADMIN |
|
||||
| PUT | `/api/admin/promotions/{id}` | 编辑活动 | ADMIN |
|
||||
| PUT | `/api/admin/promotions/{id}/status` | 手动开始/暂停/结束 | ADMIN |
|
||||
| POST | `/api/admin/promotions/{id}/skills` | 批量添加活动Skill(设置活动价) | ADMIN |
|
||||
| DELETE | `/api/admin/promotions/{id}/skills/{skillId}` | 移除活动Skill | ADMIN |
|
||||
| GET | `/api/admin/promotions` | 活动列表(分页+筛选) | ADMIN |
|
||||
| GET | `/api/admin/promotions/{id}/stats` | 活动数据(浏览/下单/成交) | ADMIN |
|
||||
|
||||
### 3.5 核心业务逻辑
|
||||
|
||||
#### Skill详情页 — 活动价展示
|
||||
```
|
||||
SkillService.getDetail(skillId):
|
||||
1. 查 skills 表获取原价
|
||||
2. 查 promotion_skills JOIN promotion_activities
|
||||
WHERE skill_id=? AND status='active' AND NOW() BETWEEN start_time AND end_time
|
||||
3. 若命中活动 → 返回 promoPrice + 活动标签 + 倒计时endTime
|
||||
4. 优先级: 秒杀 > 限时折扣 > 满减(同一Skill只取优先级最高的活动)
|
||||
```
|
||||
|
||||
#### 下单时活动价锁定(改造 OrderServiceImpl.createOrder)
|
||||
```
|
||||
1. 创建订单时,检查 skillIds 是否命中活动
|
||||
2. 若命中:
|
||||
a. 校验活动状态(active) + 时间范围
|
||||
b. 校验库存: Redis DECR promo:stock:{activityId}:{skillId}
|
||||
c. 使用 promo_price 替代 skill.price 计算 totalAmount
|
||||
d. order_items 新增字段:
|
||||
- activity_id BIGINT COMMENT '活动ID'
|
||||
- promo_price DECIMAL(10,2) COMMENT '活动价快照'
|
||||
3. 若未命中活动 → 原逻辑不变
|
||||
|
||||
— 注: 活动价与优惠券可叠加(视业务决策,可配置互斥)
|
||||
```
|
||||
|
||||
#### 秒杀流程(高并发防护)
|
||||
```
|
||||
1. 预热(活动开始前5分钟):
|
||||
FlashSaleWarmUpTask → Redis SET promo:stock:{activityId}:{skillId} = total_stock
|
||||
|
||||
2. 秒杀下单:
|
||||
a. 前端按钮 → POST /api/orders (带 activityId)
|
||||
b. 后端先 Redis 校验:
|
||||
- 防刷: SET NX promo:lock:{userId}:{activityId}:{skillId} EX 5 (5秒内不可重复)
|
||||
- 扣库存: DECR promo:stock:{activityId}:{skillId}
|
||||
- 若 < 0 → INCR 回补 → 返回"已售罄"
|
||||
c. Redis通过后 → 发送 MQ 异步创建订单
|
||||
d. 前端轮询订单状态 or WebSocket推送
|
||||
|
||||
3. 补偿:
|
||||
- 订单超时取消/退款 → INCR 回补 Redis 库存
|
||||
- DB sold_count 定时与 Redis 同步校正
|
||||
```
|
||||
|
||||
#### 定时任务 — 活动状态流转
|
||||
```
|
||||
PromotionStatusTask (@Scheduled fixedRate=60000):
|
||||
1. UPDATE promotion_activities SET status='active'
|
||||
WHERE status='pending' AND start_time <= NOW()
|
||||
2. UPDATE promotion_activities SET status='ended'
|
||||
WHERE status='active' AND end_time < NOW()
|
||||
3. 活动结束后清理 Redis 库存缓存
|
||||
```
|
||||
|
||||
### 3.6 Redis Key 设计
|
||||
|
||||
| Key | 说明 | TTL |
|
||||
|-----|------|-----|
|
||||
| `promo:stock:{activityId}:{skillId}` | 秒杀/限量库存 | 活动结束后清理 |
|
||||
| `promo:lock:{userId}:{activityId}:{skillId}` | 秒杀防刷锁 | 5秒 |
|
||||
| `promo:active:list` | 进行中活动列表缓存 | 1分钟 |
|
||||
| `promo:skill:{skillId}` | Skill当前活动价缓存 | 1分钟 |
|
||||
|
||||
---
|
||||
|
||||
## 四、订单模块改造
|
||||
|
||||
### 4.1 orders 表新增字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE orders ADD COLUMN coupon_id BIGINT COMMENT '使用的优惠券ID' AFTER points_deduct_amount;
|
||||
ALTER TABLE orders ADD COLUMN coupon_deduct_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '优惠券抵扣金额' AFTER coupon_id;
|
||||
```
|
||||
|
||||
### 4.2 order_items 表新增字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE order_items ADD COLUMN activity_id BIGINT COMMENT '促销活动ID' AFTER skill_cover;
|
||||
ALTER TABLE order_items ADD COLUMN promo_price DECIMAL(10,2) COMMENT '活动价快照' AFTER activity_id;
|
||||
```
|
||||
|
||||
### 4.3 previewOrder 改造
|
||||
|
||||
```
|
||||
入参新增: Long couponId, Long activityId(可选)
|
||||
计算顺序:
|
||||
1. 获取Skill价格 → 检查是否命中活动 → 用活动价替代原价
|
||||
2. 计算 totalAmount (可能已是活动价)
|
||||
3. 若有 couponId → 计算优惠券抵扣 → couponDeductAmount
|
||||
4. afterCouponAmount = totalAmount - couponDeductAmount
|
||||
5. 计算积分抵扣(基于 afterCouponAmount)
|
||||
6. cashAmount = afterCouponAmount - pointsDeductAmount
|
||||
|
||||
返回 OrderPreviewVO 新增:
|
||||
- couponDeductAmount
|
||||
- couponName (所用券名)
|
||||
- activityName (活动名)
|
||||
- originalTotalAmount (原总价,用于前端划线价)
|
||||
- savedAmount (总共节省金额)
|
||||
```
|
||||
|
||||
### 4.4 createOrder 改造
|
||||
|
||||
```
|
||||
入参新增: Long couponId
|
||||
核心流程:
|
||||
1~3. 原有校验 + 活动价替代 + 券核销
|
||||
4. 重新计算: totalAmount → couponDeduct → pointsDeduct → cashAmount
|
||||
5. 写入 orders (含 coupon_id, coupon_deduct_amount)
|
||||
6. 写入 order_items (含 activity_id, promo_price)
|
||||
7. 冻结积分 + 发MQ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、前端方案
|
||||
|
||||
### 5.1 技术栈
|
||||
- Vue 3 + Ant Design Vue + Element Plus
|
||||
- Pinia 状态管理
|
||||
- Axios 请求
|
||||
|
||||
### 5.2 新增页面/组件
|
||||
|
||||
#### 用户端
|
||||
|
||||
| 路由 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/user/coupons` | `views/user/coupons.vue` | 我的优惠券(Tab: 未使用/已使用/已过期) |
|
||||
| `/coupons` | `views/coupon/list.vue` | 领券中心(可领券列表+一键领取) |
|
||||
| `/promotions` | `views/promotion/list.vue` | 活动专区(进行中活动列表) |
|
||||
| `/promotions/:id` | `views/promotion/detail.vue` | 活动详情(活动Skill列表+活动价) |
|
||||
| `/flash-sale` | `views/promotion/flash-sale.vue` | 秒杀专区(场次+倒计时+秒杀按钮) |
|
||||
| — | `components/CouponCard.vue` | 优惠券卡片组件(领取/使用) |
|
||||
| — | `components/CouponSelect.vue` | 下单时券选择弹窗 |
|
||||
| — | `components/PromoTag.vue` | 活动价标签(Skill卡片/详情页) |
|
||||
| — | `components/CountdownTimer.vue` | 倒计时组件 |
|
||||
|
||||
#### 管理端
|
||||
|
||||
| 路由 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/admin/coupons` | `views/admin/coupons.vue` | 券模板管理(CRUD+状态切换) |
|
||||
| `/admin/coupons/:id` | `views/admin/coupon-detail.vue` | 券统计详情(发放/使用率) |
|
||||
| `/admin/promotions` | `views/admin/promotions.vue` | 促销活动管理(CRUD+关联Skill) |
|
||||
| `/admin/promotions/:id` | `views/admin/promotion-detail.vue` | 活动详情(关联Skill管理+数据统计) |
|
||||
|
||||
### 5.3 现有页面改造
|
||||
|
||||
#### Skill详情页 (`views/skill/detail.vue`)
|
||||
```
|
||||
改造点:
|
||||
1. 价格区域: 原价 + 活动价(划线价) + 活动标签 + 倒计时
|
||||
2. 购买弹窗: 增加"选择优惠券"入口 → CouponSelect弹窗
|
||||
3. 价格预览: 显示 原价 - 活动优惠 - 券优惠 - 积分抵扣 = 实付金额
|
||||
```
|
||||
|
||||
#### 订单详情页 (`views/order/detail.vue`)
|
||||
```
|
||||
改造点:
|
||||
1. 金额明细: 增加"优惠券抵扣"行 + "活动优惠"行
|
||||
2. 显示使用的券名称 + 活动名称
|
||||
```
|
||||
|
||||
#### 首页 (`views/home/index.vue`)
|
||||
```
|
||||
改造点:
|
||||
1. 新增"限时秒杀"模块(横向滚动,显示倒计时+秒杀价)
|
||||
2. 新增"领券中心"入口(悬浮icon或Banner位)
|
||||
3. 活动Banner(复用现有轮播图,linkType新增 promotion 类型)
|
||||
```
|
||||
|
||||
### 5.4 API Service 新增
|
||||
|
||||
```javascript
|
||||
// service/couponApi.js
|
||||
export const couponApi = {
|
||||
// 用户端
|
||||
getAvailable: () => get('/api/coupons/available'),
|
||||
receiveCoupon: (templateId) => post(`/api/coupons/${templateId}/receive`),
|
||||
getMyCoupons: (params) => get('/api/coupons/my', { params }),
|
||||
getUsableCoupons: (skillIds) => get('/api/coupons/usable', { params: { skillIds: skillIds.join(',') } }),
|
||||
calcDiscount: (data) => post('/api/coupons/calc', data),
|
||||
}
|
||||
|
||||
// service/promotionApi.js
|
||||
export const promotionApi = {
|
||||
// 用户端
|
||||
getActiveList: () => get('/api/promotions'),
|
||||
getDetail: (id) => get(`/api/promotions/${id}`),
|
||||
getSkillPromo: (skillId) => get(`/api/promotions/skill/${skillId}`),
|
||||
getFlashSales: () => get('/api/promotions/flash-sales'),
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 关键交互流程
|
||||
|
||||
#### 领券流程
|
||||
```
|
||||
领券中心页面:
|
||||
1. 加载可领券列表(带已领/已抢光状态)
|
||||
2. 点击"立即领取" → loading + 防抖
|
||||
3. 成功 → 动画(券飞入钱包) + toast提示
|
||||
4. 失败 → 提示(已领完/超过限领数/已领过)
|
||||
```
|
||||
|
||||
#### 下单选券流程
|
||||
```
|
||||
Skill详情页 → 点击购买:
|
||||
1. 调用 previewOrder(不带券) → 显示基础价格
|
||||
2. 底部显示"有N张可用优惠券" → 点击展开 CouponSelect
|
||||
3. CouponSelect: 显示可用券列表(按优惠金额排序,最优推荐)
|
||||
4. 选中券 → 重新调用 previewOrder(带couponId) → 更新价格预览
|
||||
5. 确认下单 → createOrder(带couponId)
|
||||
```
|
||||
|
||||
#### 秒杀流程
|
||||
```
|
||||
秒杀专区页:
|
||||
1. 显示场次列表 + 倒计时(距开始/距结束)
|
||||
2. 未开始: 按钮灰色"即将开始" + 倒计时
|
||||
3. 进行中: 按钮红色"立即抢购" / 已售罄灰色
|
||||
4. 点击抢购 → 弹窗确认 → POST 下单
|
||||
5. 后端返回"排队中" → 前端轮询订单状态(每2秒)
|
||||
6. 成功 → 跳转支付页 / 失败 → 提示"未抢到"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实施计划
|
||||
|
||||
### Phase 1: 优惠券系统(7-8天)
|
||||
|
||||
| 天数 | 后端任务 | 前端任务 |
|
||||
|------|---------|---------|
|
||||
| D1 | 建表 + Entity/Repository | — |
|
||||
| D2 | CouponService(创建模板/领券/查可用券) | — |
|
||||
| D3 | CouponService(优惠计算/核销/退券) + Redis库存 | CouponCard组件 + 领券中心页 |
|
||||
| D4 | AdminCouponController + 管理端API | 我的优惠券页 |
|
||||
| D5 | OrderService改造(previewOrder/createOrder增加券) | CouponSelect选券弹窗 |
|
||||
| D6 | 取消退款退券 + 定时过期任务 | Skill详情页+订单详情改造 |
|
||||
| D7 | 联调 + 边界测试 | 管理端券模板管理页 |
|
||||
| D8 | 压测领券并发 + 修复 | 联调 + UI打磨 |
|
||||
|
||||
### Phase 2: 活动系统 — 限时折扣(5-6天)
|
||||
|
||||
| 天数 | 后端任务 | 前端任务 |
|
||||
|------|---------|---------|
|
||||
| D9 | 建表 + Entity/Repository | — |
|
||||
| D10 | PromotionService(CRUD/状态流转/活动价查询) | 活动专区列表页 |
|
||||
| D11 | Skill详情接入活动价 + Order改造 | 活动详情页 + PromoTag组件 |
|
||||
| D12 | AdminPromotionController + 关联Skill | Skill详情页活动价展示 |
|
||||
| D13 | 定时任务(状态流转) + 联调 | 管理端活动管理页 |
|
||||
| D14 | 集成测试 + 修复 | 首页活动模块 + 联调 |
|
||||
|
||||
### Phase 3: 秒杀系统(4-5天,可独立排期)
|
||||
|
||||
| 天数 | 后端任务 | 前端任务 |
|
||||
|------|---------|---------|
|
||||
| D15 | 秒杀场次表 + 预热任务 + Redis库存 | — |
|
||||
| D16 | 秒杀下单(Redis防刷+MQ异步) | 秒杀专区页 + 倒计时组件 |
|
||||
| D17 | 补偿(取消回补库存) + 库存校正 | 秒杀下单交互(轮询/状态) |
|
||||
| D18 | 压测(JMeter模拟并发抢购) | 联调 + 异常处理 |
|
||||
| D19 | 修复 + 优化 | UI打磨 + 最终联调 |
|
||||
|
||||
**总计:约 17-19 天**(优惠券8天 + 限时折扣6天 + 秒杀5天)
|
||||
|
||||
---
|
||||
|
||||
## 七、注意事项
|
||||
|
||||
### 安全
|
||||
- **领券防刷**: Redis分布式锁 + 用户限领校验(DB唯一约束兜底)
|
||||
- **金额服务端计算**: 前端只展示,**所有优惠金额在后端重新计算**,不信任客户端传入
|
||||
- **库存超卖防护**: Redis原子操作(DECR) + DB乐观锁(sold_count CAS)双重保障
|
||||
- **优惠叠加控制**: 默认活动价+优惠券可叠加,但需配置互斥开关
|
||||
|
||||
### 性能
|
||||
- **热点数据缓存**: 活动列表/活动价/券库存走Redis,DB只做持久化
|
||||
- **秒杀异步**: 下单请求先入MQ,异步消费创建订单,前端轮询结果
|
||||
- **批量操作**: 券过期/活动状态更新分批执行(每批1000条)
|
||||
|
||||
### 一致性
|
||||
- **券库存**: Redis预扣 → DB写入 → 失败回补Redis(最终一致)
|
||||
- **活动库存**: Redis + DB双写,定时任务校正偏差
|
||||
- **退款退券**: 事务内同步处理(券退回 + 库存回补)
|
||||
|
||||
### 与现有系统集成
|
||||
- **会员折扣**: 会员等级折扣与活动价/优惠券的优先级: 活动价 > 优惠券 > 会员折扣
|
||||
- **积分抵扣**: 积分在最终实付金额(扣除活动优惠和券优惠后)上计算
|
||||
- **MQ补偿**: 复用现有 CompensationService,秒杀/券相关MQ失败自动写入补偿表
|
||||
|
||||
---
|
||||
|
||||
*本文档将随开发进度持续更新*
|
||||
@@ -1,431 +0,0 @@
|
||||
# 积分服务开发文档
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### UserPoints.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_points")
|
||||
public class UserPoints {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Integer availablePoints;
|
||||
private Integer frozenPoints;
|
||||
private Integer totalEarned;
|
||||
private Integer totalConsumed;
|
||||
private LocalDate lastSignInDate;
|
||||
private Integer signInStreak;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### PointsRecord.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("points_records")
|
||||
public class PointsRecord {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String pointsType; // earn / consume / freeze / unfreeze
|
||||
private String source; // register/sign_in/invite/...
|
||||
private Integer amount;
|
||||
private Integer balance;
|
||||
private String description;
|
||||
private Long relatedId;
|
||||
private String relatedType;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
### PointsRule.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("points_rules")
|
||||
public class PointsRule {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Integer id;
|
||||
private String ruleName;
|
||||
private String source;
|
||||
private Integer pointsAmount;
|
||||
private Integer frequencyLimit;
|
||||
private String frequencyPeriod; // daily/weekly/monthly/unlimited
|
||||
private Boolean enabled;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、VO
|
||||
|
||||
### PointsBalanceVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class PointsBalanceVO {
|
||||
private Integer availablePoints;
|
||||
private Integer frozenPoints;
|
||||
private Integer totalEarned;
|
||||
private Integer totalConsumed;
|
||||
private LocalDate lastSignInDate;
|
||||
private Integer signInStreak;
|
||||
private Boolean signedInToday; // 今日是否已签到
|
||||
}
|
||||
```
|
||||
|
||||
### PointsRecordVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class PointsRecordVO {
|
||||
private Long id;
|
||||
private String pointsType;
|
||||
private String source;
|
||||
private String sourceLabel; // 中文描述
|
||||
private Integer amount;
|
||||
private Integer balance;
|
||||
private String description;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、Service 接口
|
||||
|
||||
### PointsService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface PointsService {
|
||||
/** 初始化用户积分账户(注册时调用) */
|
||||
void initUserPoints(Long userId);
|
||||
|
||||
/** 获取积分余额 */
|
||||
PointsBalanceVO getBalance(Long userId);
|
||||
|
||||
/** 获取积分流水(分页) */
|
||||
IPage<PointsRecordVO> getRecords(Long userId, int pageNum, int pageSize);
|
||||
|
||||
/** 每日签到 */
|
||||
int signIn(Long userId);
|
||||
|
||||
/** 按规则发放积分(注册/邀请/加群/评价等) */
|
||||
void earnPoints(Long userId, String source, Long relatedId, String relatedType);
|
||||
|
||||
/** 消耗积分(购买Skill) */
|
||||
void consumePoints(Long userId, int amount, Long relatedId, String relatedType);
|
||||
|
||||
/** 冻结积分(下单时) */
|
||||
void freezePoints(Long userId, int amount, Long orderId);
|
||||
|
||||
/** 解冻积分(取消订单时) */
|
||||
void unfreezePoints(Long userId, int amount, Long orderId);
|
||||
|
||||
/** 检查积分是否充足 */
|
||||
boolean hasEnoughPoints(Long userId, int required);
|
||||
}
|
||||
```
|
||||
|
||||
## 四、Service 实现
|
||||
|
||||
### PointsServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.PointsService;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PointsServiceImpl implements PointsService {
|
||||
|
||||
private final UserPointsRepository userPointsRepo;
|
||||
private final PointsRecordRepository recordRepo;
|
||||
private final PointsRuleRepository ruleRepo;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void initUserPoints(Long userId) {
|
||||
UserPoints up = new UserPoints();
|
||||
up.setUserId(userId);
|
||||
up.setAvailablePoints(0);
|
||||
up.setFrozenPoints(0);
|
||||
up.setTotalEarned(0);
|
||||
up.setTotalConsumed(0);
|
||||
up.setSignInStreak(0);
|
||||
userPointsRepo.insert(up);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PointsBalanceVO getBalance(Long userId) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
PointsBalanceVO vo = new PointsBalanceVO();
|
||||
if (up == null) return vo;
|
||||
vo.setAvailablePoints(up.getAvailablePoints());
|
||||
vo.setFrozenPoints(up.getFrozenPoints());
|
||||
vo.setTotalEarned(up.getTotalEarned());
|
||||
vo.setTotalConsumed(up.getTotalConsumed());
|
||||
vo.setLastSignInDate(up.getLastSignInDate());
|
||||
vo.setSignInStreak(up.getSignInStreak());
|
||||
vo.setSignedInToday(LocalDate.now().equals(up.getLastSignInDate()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<PointsRecordVO> getRecords(Long userId, int pageNum, int pageSize) {
|
||||
Page<PointsRecord> page = new Page<>(pageNum, pageSize);
|
||||
IPage<PointsRecord> result = recordRepo.selectPage(page,
|
||||
new LambdaQueryWrapper<PointsRecord>()
|
||||
.eq(PointsRecord::getUserId, userId)
|
||||
.orderByDesc(PointsRecord::getCreatedAt));
|
||||
return result.convert(this::toRecordVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int signIn(Long userId) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
// 今日已签到
|
||||
if (today.equals(up.getLastSignInDate())) {
|
||||
throw new BusinessException(ErrorCode.ALREADY_SIGNED_IN);
|
||||
}
|
||||
|
||||
// 计算连续签到天数
|
||||
boolean consecutive = up.getLastSignInDate() != null &&
|
||||
today.minusDays(1).equals(up.getLastSignInDate());
|
||||
int streak = consecutive ? up.getSignInStreak() + 1 : 1;
|
||||
|
||||
// 签到积分:连续签到递增,最高20分
|
||||
int points = Math.min(5 + (streak - 1) * 1, 20);
|
||||
|
||||
up.setLastSignInDate(today);
|
||||
up.setSignInStreak(streak);
|
||||
userPointsRepo.updateById(up);
|
||||
|
||||
addPoints(userId, "earn", "sign_in", points, points, "每日签到", null, null);
|
||||
return points;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void earnPoints(Long userId, String source, Long relatedId, String relatedType) {
|
||||
PointsRule rule = ruleRepo.findBySource(source);
|
||||
if (rule == null || !rule.getEnabled()) return;
|
||||
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
int newBalance = up.getAvailablePoints() + rule.getPointsAmount();
|
||||
addPoints(userId, "earn", source, rule.getPointsAmount(), newBalance,
|
||||
rule.getRuleName(), relatedId, relatedType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void consumePoints(Long userId, int amount, Long relatedId, String relatedType) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
if (up.getAvailablePoints() < amount) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||
int newBalance = up.getAvailablePoints() - amount;
|
||||
addPoints(userId, "consume", "skill_purchase", -amount, newBalance,
|
||||
"兑换Skill", relatedId, relatedType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void freezePoints(Long userId, int amount, Long orderId) {
|
||||
userPointsRepo.freezePoints(userId, amount);
|
||||
addPoints(userId, "freeze", "skill_purchase", -amount,
|
||||
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
||||
"积分冻结-订单" + orderId, orderId, "order");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void unfreezePoints(Long userId, int amount, Long orderId) {
|
||||
userPointsRepo.unfreezePoints(userId, amount);
|
||||
addPoints(userId, "unfreeze", "skill_purchase", amount,
|
||||
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
||||
"积分解冻-订单取消" + orderId, orderId, "order");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasEnoughPoints(Long userId, int required) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
return up != null && up.getAvailablePoints() >= required;
|
||||
}
|
||||
|
||||
private void addPoints(Long userId, String type, String source, int amount,
|
||||
int balance, String desc, Long relatedId, String relatedType) {
|
||||
// 更新账户
|
||||
if ("earn".equals(type)) {
|
||||
userPointsRepo.addAvailablePoints(userId, amount);
|
||||
} else if ("consume".equals(type)) {
|
||||
userPointsRepo.addAvailablePoints(userId, amount); // amount为负数
|
||||
userPointsRepo.addTotalConsumed(userId, -amount);
|
||||
}
|
||||
userPointsRepo.addTotalEarned(userId, "earn".equals(type) ? amount : 0);
|
||||
|
||||
// 记录流水
|
||||
PointsRecord r = new PointsRecord();
|
||||
r.setUserId(userId);
|
||||
r.setPointsType(type);
|
||||
r.setSource(source);
|
||||
r.setAmount(amount);
|
||||
r.setBalance(balance);
|
||||
r.setDescription(desc);
|
||||
r.setRelatedId(relatedId);
|
||||
r.setRelatedType(relatedType);
|
||||
recordRepo.insert(r);
|
||||
}
|
||||
|
||||
private PointsRecordVO toRecordVO(PointsRecord r) {
|
||||
PointsRecordVO vo = new PointsRecordVO();
|
||||
vo.setId(r.getId());
|
||||
vo.setPointsType(r.getPointsType());
|
||||
vo.setSource(r.getSource());
|
||||
vo.setSourceLabel(getSourceLabel(r.getSource()));
|
||||
vo.setAmount(r.getAmount());
|
||||
vo.setBalance(r.getBalance());
|
||||
vo.setDescription(r.getDescription());
|
||||
vo.setCreatedAt(r.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void addPointsDirectly(Long userId, int amount, String source,
|
||||
Long relatedId, String desc) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
if (up == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
int newBalance = up.getAvailablePoints() + amount;
|
||||
if (newBalance < 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||
String type = amount >= 0 ? "earn" : "consume";
|
||||
addPoints(userId, type, source, amount, newBalance, desc, relatedId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ensureNotNegative(Long userId) {
|
||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||
if (up != null && up.getAvailablePoints() < 0) {
|
||||
// 强制归零,记录一条修正流水
|
||||
int diff = -up.getAvailablePoints();
|
||||
addPoints(userId, "admin_correct", "admin_adjust", diff, 0,
|
||||
"积分余额修正(防负)", null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private String getSourceLabel(String source) {
|
||||
return switch (source) {
|
||||
case "register" -> "新用户注册";
|
||||
case "sign_in" -> "每日签到";
|
||||
case "invite" -> "邀请好友";
|
||||
case "join_community" -> "加入社群";
|
||||
case "recharge" -> "充值赠送";
|
||||
case "skill_purchase" -> "兑换Skill";
|
||||
case "review" -> "发表评价";
|
||||
case "activity" -> "活动奖励";
|
||||
case "admin_adjust" -> "管理员调整";
|
||||
default -> source;
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### PointsController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.service.PointsService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/points")
|
||||
@RequiredArgsConstructor
|
||||
public class PointsController {
|
||||
|
||||
private final PointsService pointsService;
|
||||
|
||||
/** 获取积分余额 */
|
||||
@GetMapping("/balance")
|
||||
public Result<PointsBalanceVO> getBalance() {
|
||||
return Result.ok(pointsService.getBalance(UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 获取积分流水 */
|
||||
@GetMapping("/records")
|
||||
public Result<IPage<PointsRecordVO>> getRecords(
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
return Result.ok(pointsService.getRecords(UserContext.getUserId(), pageNum, pageSize));
|
||||
}
|
||||
|
||||
/** 每日签到 */
|
||||
@PostMapping("/sign-in")
|
||||
public Result<Integer> signIn() {
|
||||
int earned = pointsService.signIn(UserContext.getUserId());
|
||||
return Result.ok(earned);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**创建日期**:2026-03-16
|
||||
@@ -1,238 +0,0 @@
|
||||
# 订单服务开发文档 - Part 1(Entity + DTO + Service接口)
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### Order.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("orders")
|
||||
public class Order {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String orderNo;
|
||||
private Long userId;
|
||||
private BigDecimal totalAmount;
|
||||
private BigDecimal cashAmount;
|
||||
private Integer pointsUsed;
|
||||
private BigDecimal pointsDeductAmount;
|
||||
private String status; // pending/paid/completed/cancelled/refunding/refunded
|
||||
private String paymentMethod; // wechat/alipay/points/mixed
|
||||
private String remark;
|
||||
private String cancelReason;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime paidAt;
|
||||
private LocalDateTime expiredAt;
|
||||
}
|
||||
```
|
||||
|
||||
### OrderItem.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("order_items")
|
||||
public class OrderItem {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long orderId;
|
||||
private Long skillId;
|
||||
private String skillName; // 下单时快照
|
||||
private String skillCover; // 下单时快照
|
||||
private BigDecimal unitPrice;
|
||||
private Integer quantity;
|
||||
private BigDecimal totalPrice;
|
||||
}
|
||||
```
|
||||
|
||||
### OrderRefund.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("order_refunds")
|
||||
public class OrderRefund {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long orderId;
|
||||
private String refundNo;
|
||||
private BigDecimal refundAmount;
|
||||
private Integer refundPoints;
|
||||
private String reason;
|
||||
private String images; // JSON
|
||||
private String status; // pending/approved/rejected/completed
|
||||
private String rejectReason;
|
||||
private Long operatorId; // 处理人ID
|
||||
private LocalDateTime processedAt; // 处理时间
|
||||
private String remark; // 处理备注
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、DTO / VO
|
||||
|
||||
### OrderCreateDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class OrderCreateDTO {
|
||||
@NotEmpty(message = "请选择要购买的Skill")
|
||||
private List<Long> skillIds;
|
||||
private Integer pointsToUse = 0; // 使用积分数
|
||||
private String paymentMethod; // wechat/alipay/points/mixed
|
||||
}
|
||||
```
|
||||
|
||||
### RefundApplyDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class RefundApplyDTO {
|
||||
@NotBlank(message = "请填写退款原因")
|
||||
private String reason;
|
||||
private List<String> images; // 腾讯云COS URL
|
||||
}
|
||||
```
|
||||
|
||||
### OrderVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class OrderVO {
|
||||
private Long id;
|
||||
private String orderNo;
|
||||
private BigDecimal totalAmount;
|
||||
private BigDecimal cashAmount;
|
||||
private Integer pointsUsed;
|
||||
private BigDecimal pointsDeductAmount;
|
||||
private String status;
|
||||
private String statusLabel; // 中文状态
|
||||
private String paymentMethod;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime paidAt;
|
||||
private List<OrderItemVO> items;
|
||||
}
|
||||
```
|
||||
|
||||
### OrderItemVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class OrderItemVO {
|
||||
private Long skillId;
|
||||
private String skillName;
|
||||
private String skillCover;
|
||||
private BigDecimal unitPrice;
|
||||
private Integer quantity;
|
||||
private BigDecimal totalPrice;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、Service 接口
|
||||
|
||||
### OrderService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface OrderService {
|
||||
/** 创建订单(含积分抵扣计算) */
|
||||
OrderVO createOrder(Long userId, OrderCreateDTO dto);
|
||||
|
||||
/** 订单详情 */
|
||||
OrderVO getOrderDetail(Long orderId, Long userId);
|
||||
|
||||
/** 订单列表(分页) */
|
||||
IPage<OrderVO> listOrders(Long userId, String status, int pageNum, int pageSize);
|
||||
|
||||
/** 取消订单 */
|
||||
void cancelOrder(Long orderId, Long userId, String reason);
|
||||
|
||||
/** 支付成功回调处理 */
|
||||
void handlePaySuccess(String orderNo, String transactionId);
|
||||
|
||||
/** 申请退款 */
|
||||
void applyRefund(Long orderId, Long userId, RefundApplyDTO dto);
|
||||
}
|
||||
```
|
||||
|
||||
## 四、IdGenerator 工具类
|
||||
|
||||
```java
|
||||
package com.openclaw.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Component
|
||||
public class IdGenerator {
|
||||
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private final AtomicInteger seq = new AtomicInteger(1000);
|
||||
|
||||
public String generateOrderNo() {
|
||||
return LocalDateTime.now().format(FMT) + seq.incrementAndGet();
|
||||
}
|
||||
|
||||
public String generateRefundNo() {
|
||||
return "R" + LocalDateTime.now().format(FMT) + seq.incrementAndGet();
|
||||
}
|
||||
|
||||
public String generateRechargeNo() {
|
||||
return "RC" + LocalDateTime.now().format(FMT) + seq.incrementAndGet();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
@@ -1,288 +0,0 @@
|
||||
# 订单服务开发文档 - Part 2(Service实现 + Controller)
|
||||
|
||||
## 四、Service 实现
|
||||
|
||||
### OrderServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
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.IdGenerator;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderServiceImpl implements OrderService {
|
||||
|
||||
private final OrderRepository orderRepo;
|
||||
private final OrderItemRepository itemRepo;
|
||||
private final OrderRefundRepository refundRepo;
|
||||
private final SkillRepository skillRepo;
|
||||
private final SkillDownloadRepository downloadRepo;
|
||||
private final PointsService pointsService;
|
||||
private final IdGenerator idGenerator;
|
||||
|
||||
private static final BigDecimal POINTS_RATE = new BigDecimal("0.01"); // 1积分=0.01元
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public OrderVO createOrder(Long userId, OrderCreateDTO dto) {
|
||||
List<Skill> skills = new ArrayList<>();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
|
||||
for (Long skillId : dto.getSkillIds()) {
|
||||
Skill skill = skillRepo.selectById(skillId);
|
||||
if (skill == null || !"approved".equals(skill.getStatus()))
|
||||
throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
if (downloadRepo.existsByUserIdAndSkillId(userId, skillId))
|
||||
throw new BusinessException(ErrorCode.SKILL_ALREADY_OWNED);
|
||||
skills.add(skill);
|
||||
total = total.add(Boolean.TRUE.equals(skill.getIsFree()) ? BigDecimal.ZERO : skill.getPrice());
|
||||
}
|
||||
|
||||
// 积分抵扣计算
|
||||
int pts = dto.getPointsToUse() == null ? 0 : dto.getPointsToUse();
|
||||
BigDecimal ptsDed = BigDecimal.ZERO;
|
||||
if (pts > 0) {
|
||||
if (!pointsService.hasEnoughPoints(userId, pts))
|
||||
throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||
ptsDed = new BigDecimal(pts).multiply(POINTS_RATE).min(total);
|
||||
}
|
||||
BigDecimal cash = total.subtract(ptsDed);
|
||||
|
||||
// 创建订单主记录
|
||||
Order order = new Order();
|
||||
order.setOrderNo(idGenerator.generateOrderNo());
|
||||
order.setUserId(userId);
|
||||
order.setTotalAmount(total);
|
||||
order.setCashAmount(cash);
|
||||
order.setPointsUsed(pts);
|
||||
order.setPointsDeductAmount(ptsDed);
|
||||
order.setStatus("pending");
|
||||
order.setPaymentMethod(dto.getPaymentMethod());
|
||||
order.setExpiredAt(LocalDateTime.now().plusMinutes(30));
|
||||
orderRepo.insert(order);
|
||||
|
||||
// 创建订单项(快照商品信息)
|
||||
for (Skill s : skills) {
|
||||
OrderItem item = new OrderItem();
|
||||
item.setOrderId(order.getId());
|
||||
item.setSkillId(s.getId());
|
||||
item.setSkillName(s.getName());
|
||||
item.setSkillCover(s.getCoverImageUrl());
|
||||
BigDecimal price = Boolean.TRUE.equals(s.getIsFree()) ? BigDecimal.ZERO : s.getPrice();
|
||||
item.setUnitPrice(price);
|
||||
item.setQuantity(1);
|
||||
item.setTotalPrice(price);
|
||||
itemRepo.insert(item);
|
||||
}
|
||||
|
||||
// 冻结积分
|
||||
if (pts > 0) pointsService.freezePoints(userId, pts, order.getId());
|
||||
|
||||
// 纯免费/纯积分支付直接完成,无需拉起支付
|
||||
if (cash.compareTo(BigDecimal.ZERO) == 0) {
|
||||
handlePaySuccess(order.getOrderNo(), null);
|
||||
}
|
||||
|
||||
return buildOrderVO(order);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrderVO getOrderDetail(Long orderId, Long userId) {
|
||||
Order order = orderRepo.selectById(orderId);
|
||||
if (order == null || !order.getUserId().equals(userId))
|
||||
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||
return buildOrderVO(order);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<OrderVO> listOrders(Long userId, String status, int pageNum, int pageSize) {
|
||||
IPage<Order> page = orderRepo.selectPage(
|
||||
new Page<>(pageNum, pageSize),
|
||||
new LambdaQueryWrapper<Order>()
|
||||
.eq(Order::getUserId, userId)
|
||||
.eq(status != null, Order::getStatus, status)
|
||||
.orderByDesc(Order::getCreatedAt));
|
||||
return page.convert(this::buildOrderVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void cancelOrder(Long orderId, Long userId, String reason) {
|
||||
Order order = orderRepo.selectById(orderId);
|
||||
if (order == null || !order.getUserId().equals(userId))
|
||||
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||
if (!"pending".equals(order.getStatus()))
|
||||
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR);
|
||||
order.setStatus("cancelled");
|
||||
order.setCancelReason(reason);
|
||||
orderRepo.updateById(order);
|
||||
// 解冻积分
|
||||
if (order.getPointsUsed() != null && order.getPointsUsed() > 0)
|
||||
pointsService.unfreezePoints(userId, order.getPointsUsed(), orderId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handlePaySuccess(String orderNo, String transactionId) {
|
||||
Order order = orderRepo.findByOrderNo(orderNo);
|
||||
if (order == null) throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||
if ("paid".equals(order.getStatus())) return; // 幂等处理
|
||||
|
||||
order.setStatus("paid");
|
||||
order.setPaidAt(LocalDateTime.now());
|
||||
orderRepo.updateById(order);
|
||||
|
||||
// 消耗冻结积分(正式扣减)
|
||||
if (order.getPointsUsed() != null && order.getPointsUsed() > 0)
|
||||
pointsService.consumePoints(order.getUserId(), order.getPointsUsed(), order.getId(), "order");
|
||||
|
||||
// 授权Skill访问权限
|
||||
String dlType = (order.getPointsUsed() != null && order.getPointsUsed() > 0
|
||||
&& order.getCashAmount().compareTo(BigDecimal.ZERO) == 0) ? "points" : "paid";
|
||||
itemRepo.findByOrderId(order.getId()).forEach(item ->
|
||||
downloadRepo.grantAccess(order.getUserId(), item.getSkillId(), order.getId(), dlType));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void applyRefund(Long orderId, Long userId, RefundApplyDTO dto) {
|
||||
Order order = orderRepo.selectById(orderId);
|
||||
if (order == null || !order.getUserId().equals(userId))
|
||||
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||
if (!"paid".equals(order.getStatus()) && !"completed".equals(order.getStatus()))
|
||||
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR);
|
||||
order.setStatus("refunding");
|
||||
orderRepo.updateById(order);
|
||||
|
||||
OrderRefund refund = new OrderRefund();
|
||||
refund.setOrderId(orderId);
|
||||
refund.setRefundNo(idGenerator.generateRefundNo());
|
||||
refund.setRefundAmount(order.getCashAmount());
|
||||
refund.setRefundPoints(order.getPointsUsed());
|
||||
refund.setReason(dto.getReason());
|
||||
if (dto.getImages() != null) refund.setImages(dto.getImages().toString());
|
||||
refund.setStatus("pending");
|
||||
refundRepo.insert(refund);
|
||||
}
|
||||
|
||||
private OrderVO buildOrderVO(Order order) {
|
||||
OrderVO vo = new OrderVO();
|
||||
vo.setId(order.getId());
|
||||
vo.setOrderNo(order.getOrderNo());
|
||||
vo.setTotalAmount(order.getTotalAmount());
|
||||
vo.setCashAmount(order.getCashAmount());
|
||||
vo.setPointsUsed(order.getPointsUsed());
|
||||
vo.setPointsDeductAmount(order.getPointsDeductAmount());
|
||||
vo.setStatus(order.getStatus());
|
||||
vo.setStatusLabel(switch (order.getStatus()) {
|
||||
case "pending" -> "待支付";
|
||||
case "paid" -> "已支付";
|
||||
case "completed" -> "已完成";
|
||||
case "cancelled" -> "已取消";
|
||||
case "refunding" -> "退款中";
|
||||
case "refunded" -> "已退款";
|
||||
default -> order.getStatus();
|
||||
});
|
||||
vo.setPaymentMethod(order.getPaymentMethod());
|
||||
vo.setCreatedAt(order.getCreatedAt());
|
||||
vo.setPaidAt(order.getPaidAt());
|
||||
vo.setItems(itemRepo.findByOrderId(order.getId()).stream().map(i -> {
|
||||
OrderItemVO iv = new OrderItemVO();
|
||||
iv.setSkillId(i.getSkillId());
|
||||
iv.setSkillName(i.getSkillName());
|
||||
iv.setSkillCover(i.getSkillCover());
|
||||
iv.setUnitPrice(i.getUnitPrice());
|
||||
iv.setQuantity(i.getQuantity());
|
||||
iv.setTotalPrice(i.getTotalPrice());
|
||||
return iv;
|
||||
}).toList());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### OrderController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.*;
|
||||
import com.openclaw.service.OrderService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/orders")
|
||||
@RequiredArgsConstructor
|
||||
public class OrderController {
|
||||
|
||||
private final OrderService orderService;
|
||||
|
||||
/** 创建订单 */
|
||||
@PostMapping
|
||||
public Result<OrderVO> createOrder(@Valid @RequestBody OrderCreateDTO dto) {
|
||||
return Result.ok(orderService.createOrder(UserContext.getUserId(), dto));
|
||||
}
|
||||
|
||||
/** 订单详情 */
|
||||
@GetMapping("/{id}")
|
||||
public Result<OrderVO> getDetail(@PathVariable Long id) {
|
||||
return Result.ok(orderService.getOrderDetail(id, UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 订单列表 */
|
||||
@GetMapping
|
||||
public Result<IPage<OrderVO>> listOrders(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
return Result.ok(orderService.listOrders(UserContext.getUserId(), status, pageNum, pageSize));
|
||||
}
|
||||
|
||||
/** 取消订单 */
|
||||
@PutMapping("/{id}/cancel")
|
||||
public Result<Void> cancelOrder(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(required = false) String reason) {
|
||||
orderService.cancelOrder(id, UserContext.getUserId(), reason);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 申请退款 */
|
||||
@PostMapping("/{id}/refund")
|
||||
public Result<Void> applyRefund(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody RefundApplyDTO dto) {
|
||||
orderService.applyRefund(id, UserContext.getUserId(), dto);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
@@ -1,397 +0,0 @@
|
||||
# 支付服务开发文档
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### RechargeOrder.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("recharge_orders")
|
||||
public class RechargeOrder {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String rechargeNo;
|
||||
private Long userId;
|
||||
private BigDecimal amount;
|
||||
private Integer bonusPoints;
|
||||
private Integer totalPoints;
|
||||
private String paymentMethod; // wechat / alipay
|
||||
private String status; // pending/paid/failed/cancelled
|
||||
private String transactionId;
|
||||
private String notifyData; // 回调原始数据
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime paidAt;
|
||||
}
|
||||
```
|
||||
|
||||
### PaymentRecord.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("payment_records")
|
||||
public class PaymentRecord {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String bizType; // order / recharge
|
||||
private Long bizId;
|
||||
private String bizNo;
|
||||
private BigDecimal amount;
|
||||
private String paymentMethod;
|
||||
private String transactionId;
|
||||
private String status; // pending/success/failed
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、DTO / VO
|
||||
|
||||
### RechargeDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class RechargeDTO {
|
||||
@NotNull(message = "充值金额不能为空")
|
||||
@DecimalMin(value = "1.00", message = "最低充值金额1元")
|
||||
private BigDecimal amount;
|
||||
|
||||
@NotBlank(message = "支付方式不能为空")
|
||||
private String paymentMethod; // wechat / alipay
|
||||
}
|
||||
```
|
||||
|
||||
### RechargeVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class RechargeVO {
|
||||
private Long rechargeId;
|
||||
private String rechargeNo;
|
||||
private BigDecimal amount;
|
||||
private Integer bonusPoints;
|
||||
private Integer totalPoints;
|
||||
// 支付参数(前端拉起支付用)
|
||||
private String payParams; // JSON字符串,微信/支付宝支付参数
|
||||
}
|
||||
```
|
||||
|
||||
### PaymentRecordVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class PaymentRecordVO {
|
||||
private Long id;
|
||||
private String bizType;
|
||||
private String bizNo;
|
||||
private BigDecimal amount;
|
||||
private String paymentMethod;
|
||||
private String status;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、充值赠送规则配置
|
||||
|
||||
```java
|
||||
package com.openclaw.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "recharge")
|
||||
public class RechargeConfig {
|
||||
|
||||
private List<Tier> tiers;
|
||||
|
||||
@Data
|
||||
public static class Tier {
|
||||
private BigDecimal amount; // 充值金额
|
||||
private Integer bonusPoints; // 赠送积分
|
||||
}
|
||||
|
||||
/** 计算赠送积分 */
|
||||
public Integer calcBonusPoints(BigDecimal amount) {
|
||||
return tiers.stream()
|
||||
.filter(t -> amount.compareTo(t.getAmount()) >= 0)
|
||||
.mapToInt(Tier::getBonusPoints)
|
||||
.max().orElse(0);
|
||||
}
|
||||
|
||||
/** 计算到账总积分(充值金额换算为积分 + 赠送) */
|
||||
public Integer calcTotalPoints(BigDecimal amount) {
|
||||
int base = amount.multiply(BigDecimal.valueOf(100)).intValue(); // 1元=100积分
|
||||
return base + calcBonusPoints(amount);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# application.yml 充值配置
|
||||
recharge:
|
||||
tiers:
|
||||
- amount: 10
|
||||
bonusPoints: 10
|
||||
- amount: 50
|
||||
bonusPoints: 60
|
||||
- amount: 100
|
||||
bonusPoints: 150
|
||||
- amount: 500
|
||||
bonusPoints: 800
|
||||
- amount: 1000
|
||||
bonusPoints: 2000
|
||||
```
|
||||
|
||||
## 四、Service 接口 + 实现
|
||||
|
||||
### PaymentService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.RechargeDTO;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface PaymentService {
|
||||
/** 发起充值,返回支付参数 */
|
||||
RechargeVO createRecharge(Long userId, RechargeDTO dto);
|
||||
|
||||
/** 微信支付回调 */
|
||||
void handleWechatCallback(String xmlBody);
|
||||
|
||||
/** 支付宝支付回调 */
|
||||
void handleAlipayCallback(String body);
|
||||
|
||||
/** 查询充值记录 */
|
||||
IPage<PaymentRecordVO> getPaymentRecords(Long userId, int pageNum, int pageSize);
|
||||
}
|
||||
```
|
||||
|
||||
### PaymentServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.config.RechargeConfig;
|
||||
import com.openclaw.dto.RechargeDTO;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.*;
|
||||
import com.openclaw.util.IdGenerator;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentServiceImpl implements PaymentService {
|
||||
|
||||
private final RechargeOrderRepository rechargeRepo;
|
||||
private final PaymentRecordRepository paymentRecordRepo;
|
||||
private final PointsService pointsService;
|
||||
private final OrderService orderService;
|
||||
private final RechargeConfig rechargeConfig;
|
||||
private final IdGenerator idGenerator;
|
||||
// private final WechatPayClient wechatPayClient; // 微信支付SDK
|
||||
// private final AlipayClient alipayClient; // 支付宝SDK
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RechargeVO createRecharge(Long userId, RechargeDTO dto) {
|
||||
int bonus = rechargeConfig.calcBonusPoints(dto.getAmount());
|
||||
int total = rechargeConfig.calcTotalPoints(dto.getAmount());
|
||||
|
||||
RechargeOrder order = new RechargeOrder();
|
||||
order.setRechargeNo(idGenerator.generateRechargeNo());
|
||||
order.setUserId(userId);
|
||||
order.setAmount(dto.getAmount());
|
||||
order.setBonusPoints(bonus);
|
||||
order.setTotalPoints(total);
|
||||
order.setPaymentMethod(dto.getPaymentMethod());
|
||||
order.setStatus("pending");
|
||||
rechargeRepo.insert(order);
|
||||
|
||||
// TODO: 调用微信/支付宝SDK生成支付参数
|
||||
// String payParams = wechatPayClient.createPayOrder(...);
|
||||
String payParams = "{\"prepay_id\":\"mock_prepay_id\"}";
|
||||
|
||||
RechargeVO vo = new RechargeVO();
|
||||
vo.setRechargeId(order.getId());
|
||||
vo.setRechargeNo(order.getRechargeNo());
|
||||
vo.setAmount(order.getAmount());
|
||||
vo.setBonusPoints(bonus);
|
||||
vo.setTotalPoints(total);
|
||||
vo.setPayParams(payParams);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handleWechatCallback(String xmlBody) {
|
||||
// 1. 解析微信回调XML
|
||||
// 2. 验签
|
||||
// 3. 查找充值订单
|
||||
// 4. 幂等校验(已处理则直接返回)
|
||||
// 5. 更新充值订单状态
|
||||
// 6. 发放积分
|
||||
log.info("收到微信支付回调: {}", xmlBody);
|
||||
// 示例:解析 rechargeNo 后调用 completeRecharge
|
||||
// String rechargeNo = parseXml(xmlBody, "out_trade_no");
|
||||
// String transactionId = parseXml(xmlBody, "transaction_id");
|
||||
// completeRecharge(rechargeNo, transactionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handleAlipayCallback(String body) {
|
||||
log.info("收到支付宝回调: {}", body);
|
||||
// 同上,解析参数后调用 completeRecharge
|
||||
}
|
||||
|
||||
/** 充值完成:更新状态 + 发放积分 */
|
||||
private void completeRecharge(String rechargeNo, String transactionId) {
|
||||
RechargeOrder order = rechargeRepo.findByRechargeNo(rechargeNo);
|
||||
if (order == null || "paid".equals(order.getStatus())) return; // 幂等
|
||||
|
||||
order.setStatus("paid");
|
||||
order.setTransactionId(transactionId);
|
||||
import java.time.LocalDateTime;
|
||||
order.setPaidAt(LocalDateTime.now());
|
||||
rechargeRepo.updateById(order);
|
||||
|
||||
// 发放积分(充值赠送)
|
||||
pointsService.earnPoints(order.getUserId(), "recharge", order.getId(), "recharge");
|
||||
// 注意:earnPoints 里按规则取积分数,但充值积分数量是动态的,需要特殊处理
|
||||
// 可以直接调用底层方法传入 totalPoints
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<PaymentRecordVO> getPaymentRecords(Long userId, int pageNum, int pageSize) {
|
||||
IPage<PaymentRecord> page = paymentRecordRepo.selectPage(
|
||||
new Page<>(pageNum, pageSize),
|
||||
new LambdaQueryWrapper<PaymentRecord>()
|
||||
.eq(PaymentRecord::getUserId, userId)
|
||||
.orderByDesc(PaymentRecord::getCreatedAt));
|
||||
return page.convert(r -> {
|
||||
PaymentRecordVO vo = new PaymentRecordVO();
|
||||
vo.setId(r.getId());
|
||||
vo.setBizType(r.getBizType());
|
||||
vo.setBizNo(r.getBizNo());
|
||||
vo.setAmount(r.getAmount());
|
||||
vo.setPaymentMethod(r.getPaymentMethod());
|
||||
vo.setStatus(r.getStatus());
|
||||
vo.setCreatedAt(r.getCreatedAt());
|
||||
return vo;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### PaymentController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.RechargeDTO;
|
||||
import com.openclaw.service.PaymentService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/payments")
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentController {
|
||||
|
||||
private final PaymentService paymentService;
|
||||
|
||||
/** 发起充值 */
|
||||
@PostMapping("/recharge")
|
||||
public Result<RechargeVO> createRecharge(@Valid @RequestBody RechargeDTO dto) {
|
||||
return Result.ok(paymentService.createRecharge(UserContext.getUserId(), dto));
|
||||
}
|
||||
|
||||
/** 微信支付回调(无需登录) */
|
||||
@PostMapping("/callback/wechat")
|
||||
public String wechatCallback(HttpServletRequest request) throws Exception {
|
||||
String body = new BufferedReader(new InputStreamReader(request.getInputStream()))
|
||||
.lines().collect(Collectors.joining("\n"));
|
||||
paymentService.handleWechatCallback(body);
|
||||
return "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
|
||||
}
|
||||
|
||||
/** 支付宝回调(无需登录) */
|
||||
@PostMapping("/callback/alipay")
|
||||
public String alipayCallback(HttpServletRequest request) throws Exception {
|
||||
String body = new BufferedReader(new InputStreamReader(request.getInputStream()))
|
||||
.lines().collect(Collectors.joining("\n"));
|
||||
paymentService.handleAlipayCallback(body);
|
||||
return "success";
|
||||
}
|
||||
|
||||
/** 支付记录 */
|
||||
@GetMapping("/records")
|
||||
public Result<IPage<PaymentRecordVO>> getRecords(
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
return Result.ok(paymentService.getPaymentRecords(UserContext.getUserId(), pageNum, pageSize));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
@@ -1,458 +0,0 @@
|
||||
# 邀请服务开发文档
|
||||
|
||||
## 一、Entity 实体类
|
||||
|
||||
### InviteCode.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("invite_codes")
|
||||
public class InviteCode {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String code; // 邀请码(唯一)
|
||||
private Integer useCount; // 已使用次数
|
||||
private Integer maxUseCount; // 最大使用次数(-1为不限)
|
||||
private Boolean isActive; // 是否启用
|
||||
private LocalDateTime expiredAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### InviteRecord.java
|
||||
|
||||
```java
|
||||
package com.openclaw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("invite_records")
|
||||
public class InviteRecord {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long inviterId; // 邀请人
|
||||
private Long inviteeId; // 被邀请人
|
||||
private String inviteCode; // 使用的邀请码
|
||||
private String status; // pending / rewarded
|
||||
private Integer inviterRewardPoints; // 邀请人获得积分
|
||||
private Integer inviteeRewardPoints; // 被邀请人获得积分
|
||||
private LocalDateTime rewardedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、DTO / VO
|
||||
|
||||
### InviteCodeVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class InviteCodeVO {
|
||||
private String code;
|
||||
private Integer useCount;
|
||||
private Integer maxUseCount;
|
||||
private Boolean isActive;
|
||||
private LocalDateTime expiredAt;
|
||||
// 邀请链接(前端拼接用)
|
||||
private String inviteUrl;
|
||||
}
|
||||
```
|
||||
|
||||
### InviteRecordVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class InviteRecordVO {
|
||||
private Long id;
|
||||
private Long inviteeId;
|
||||
private String inviteeNickname;
|
||||
private String inviteeAvatar;
|
||||
private String status;
|
||||
private Integer inviterPoints; // 对应实体 inviterRewardPoints
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime rewardedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### BindInviteDTO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class BindInviteDTO {
|
||||
@NotBlank(message = "邀请码不能为空")
|
||||
private String inviteCode;
|
||||
}
|
||||
```
|
||||
|
||||
### InviteStatsVO.java
|
||||
|
||||
```java
|
||||
package com.openclaw.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class InviteStatsVO {
|
||||
private Integer totalInvites; // 累计邀请人数
|
||||
private Integer rewardedInvites; // 已奖励次数
|
||||
private Integer totalEarnedPoints; // 通过邀请获得的总积分
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Repository
|
||||
|
||||
### InviteCodeRepository.java
|
||||
|
||||
```java
|
||||
package com.openclaw.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.openclaw.entity.InviteCode;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface InviteCodeRepository extends BaseMapper<InviteCode> {
|
||||
|
||||
@Select("SELECT * FROM invite_codes WHERE code = #{code} AND is_active = 1 LIMIT 1")
|
||||
InviteCode findActiveByCode(String code);
|
||||
|
||||
@Select("SELECT * FROM invite_codes WHERE user_id = #{userId} LIMIT 1")
|
||||
InviteCode findByUserId(Long userId);
|
||||
}
|
||||
```
|
||||
|
||||
### InviteRecordRepository.java
|
||||
|
||||
```java
|
||||
package com.openclaw.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.openclaw.entity.InviteRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface InviteRecordRepository extends BaseMapper<InviteRecord> {
|
||||
|
||||
@Select("SELECT * FROM invite_records WHERE inviter_id = #{inviterId} AND invitee_id = #{inviteeId} LIMIT 1")
|
||||
InviteRecord findByInviterAndInvitee(Long inviterId, Long inviteeId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM invite_records WHERE invitee_id = #{inviteeId}")
|
||||
int countByInviteeId(Long inviteeId);
|
||||
|
||||
@Select("SELECT SUM(inviter_reward_points) FROM invite_records WHERE inviter_id = #{inviterId} AND status = 'rewarded'")
|
||||
Integer sumEarnedPoints(Long inviterId);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Service 接口 + 实现
|
||||
|
||||
### InviteService.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.BindInviteDTO;
|
||||
import com.openclaw.vo.*;
|
||||
|
||||
public interface InviteService {
|
||||
/** 获取(或生成)我的邀请码 */
|
||||
InviteCodeVO getMyInviteCode(Long userId);
|
||||
|
||||
/** 新用户注册后绑定邀请码(发放奖励) */
|
||||
void bindInviteCode(Long inviteeId, String inviteCode);
|
||||
|
||||
/** 查询邀请记录列表 */
|
||||
IPage<InviteRecordVO> listInviteRecords(Long userId, int pageNum, int pageSize);
|
||||
|
||||
/** 查询邀请统计数据 */
|
||||
InviteStatsVO getInviteStats(Long userId);
|
||||
}
|
||||
```
|
||||
|
||||
### InviteServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.*;
|
||||
import com.openclaw.repository.UserRepository;
|
||||
import com.openclaw.vo.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InviteServiceImpl implements InviteService {
|
||||
|
||||
private final InviteCodeRepository inviteCodeRepo;
|
||||
private final InviteRecordRepository inviteRecordRepo;
|
||||
private final UserRepository userRepo;
|
||||
private final PointsService pointsService;
|
||||
|
||||
@Value("${invite.inviter-points:50}")
|
||||
private int inviterPoints; // 邀请人奖励积分
|
||||
|
||||
@Value("${invite.invitee-points:30}")
|
||||
private int inviteePoints; // 被邀请人奖励积分
|
||||
|
||||
@Value("${invite.url-prefix:https://app.openclaw.com/invite/}")
|
||||
private String urlPrefix;
|
||||
|
||||
@Override
|
||||
public InviteCodeVO getMyInviteCode(Long userId) {
|
||||
InviteCode code = inviteCodeRepo.findByUserId(userId);
|
||||
if (code == null) {
|
||||
code = new InviteCode();
|
||||
code.setUserId(userId);
|
||||
code.setCode(generateUniqueCode());
|
||||
code.setUseCount(0);
|
||||
code.setMaxUseCount(-1); // 不限次数
|
||||
code.setIsActive(true);
|
||||
inviteCodeRepo.insert(code);
|
||||
}
|
||||
return toVO(code);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void bindInviteCode(Long inviteeId, String inviteCode) {
|
||||
// 1. 检查被邀请人是否已被邀请过
|
||||
if (inviteRecordRepo.countByInviteeId(inviteeId) > 0) {
|
||||
log.warn("用户 {} 已被邀请过,忽略重复绑定", inviteeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 校验邀请码有效性
|
||||
InviteCode code = inviteCodeRepo.findActiveByCode(inviteCode);
|
||||
if (code == null) throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
|
||||
|
||||
// 3. 邀请人不能邀请自己
|
||||
if (code.getUserId().equals(inviteeId))
|
||||
throw new BusinessException(ErrorCode.INVITE_SELF_NOT_ALLOWED);
|
||||
|
||||
// 4. 检查使用次数上限
|
||||
if (code.getMaxUseCount() > 0 && code.getUseCount() >= code.getMaxUseCount())
|
||||
throw new BusinessException(ErrorCode.INVITE_CODE_EXHAUSTED);
|
||||
|
||||
// 5. 更新邀请码使用次数
|
||||
code.setUseCount(code.getUseCount() + 1);
|
||||
inviteCodeRepo.updateById(code);
|
||||
|
||||
// 6. 创建邀请记录
|
||||
InviteRecord record = new InviteRecord();
|
||||
record.setInviterId(code.getUserId());
|
||||
record.setInviteeId(inviteeId);
|
||||
record.setInviteCode(inviteCode);
|
||||
record.setStatus("registered");
|
||||
record.setInviterRewardPoints(inviterPoints);
|
||||
record.setInviteeRewardPoints(inviteePoints);
|
||||
record.setRewardedAt(LocalDateTime.now());
|
||||
inviteRecordRepo.insert(record);
|
||||
|
||||
// 7. 发放积分
|
||||
pointsService.addPointsDirectly(code.getUserId(), inviterPoints, "invite", record.getId(), "邀请好友奖励");
|
||||
pointsService.addPointsDirectly(inviteeId, inviteePoints, "invited", record.getId(), "接受邀请奖励");
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<InviteRecordVO> listInviteRecords(Long userId, int pageNum, int pageSize) {
|
||||
IPage<InviteRecord> page = inviteRecordRepo.selectPage(
|
||||
new Page<>(pageNum, pageSize),
|
||||
new LambdaQueryWrapper<InviteRecord>()
|
||||
.eq(InviteRecord::getInviterId, userId)
|
||||
.orderByDesc(InviteRecord::getCreatedAt));
|
||||
return page.convert(r -> {
|
||||
InviteRecordVO vo = new InviteRecordVO();
|
||||
vo.setId(r.getId());
|
||||
vo.setInviteeId(r.getInviteeId());
|
||||
// 查询被邀请人信息
|
||||
User invitee = userRepo.selectById(r.getInviteeId());
|
||||
if (invitee != null) {
|
||||
vo.setInviteeNickname(invitee.getNickname());
|
||||
vo.setInviteeAvatar(invitee.getAvatarUrl());
|
||||
}
|
||||
vo.setStatus(r.getStatus());
|
||||
vo.setInviterPoints(r.getInviterRewardPoints()) // VO字段名保持简洁;
|
||||
vo.setCreatedAt(r.getCreatedAt());
|
||||
vo.setRewardedAt(r.getRewardedAt());
|
||||
return vo;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public InviteStatsVO getInviteStats(Long userId) {
|
||||
InviteStatsVO stats = new InviteStatsVO();
|
||||
stats.setTotalInvites((int) inviteRecordRepo.selectCount(
|
||||
new LambdaQueryWrapper<InviteRecord>().eq(InviteRecord::getInviterId, userId)));
|
||||
stats.setRewardedInvites((int) inviteRecordRepo.selectCount(
|
||||
new LambdaQueryWrapper<InviteRecord>()
|
||||
.eq(InviteRecord::getInviterId, userId)
|
||||
.eq(InviteRecord::getStatus, "registered")));
|
||||
Integer earned = inviteRecordRepo.sumEarnedPoints(userId);
|
||||
stats.setTotalEarnedPoints(earned == null ? 0 : earned);
|
||||
return stats;
|
||||
}
|
||||
|
||||
// --- 私有方法 ---
|
||||
|
||||
private String generateUniqueCode() {
|
||||
// 取UUID前8位,碰撞概率极低;生产环境可加重试逻辑
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
private InviteCodeVO toVO(InviteCode code) {
|
||||
InviteCodeVO vo = new InviteCodeVO();
|
||||
vo.setCode(code.getCode());
|
||||
vo.setUseCount(code.getUseCount());
|
||||
vo.setMaxUseCount(code.getMaxUseCount());
|
||||
vo.setIsActive(code.getIsActive());
|
||||
vo.setExpiredAt(code.getExpiredAt());
|
||||
vo.setInviteUrl(urlPrefix + code.getCode());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、Controller
|
||||
|
||||
### InviteController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.BindInviteDTO;
|
||||
import com.openclaw.service.InviteService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invites")
|
||||
@RequiredArgsConstructor
|
||||
public class InviteController {
|
||||
|
||||
private final InviteService inviteService;
|
||||
|
||||
/** 获取我的邀请码 */
|
||||
@GetMapping("/my-code")
|
||||
public Result<InviteCodeVO> getMyCode() {
|
||||
return Result.ok(inviteService.getMyInviteCode(UserContext.getUserId()));
|
||||
}
|
||||
|
||||
/** 新用户绑定邀请码(注册时或注册后调用) */
|
||||
@PostMapping("/bind")
|
||||
public Result<Void> bindCode(@Valid @RequestBody BindInviteDTO dto) {
|
||||
inviteService.bindInviteCode(UserContext.getUserId(), dto.getInviteCode());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 邀请记录列表 */
|
||||
@GetMapping("/records")
|
||||
public Result<IPage<InviteRecordVO>> records(
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
return Result.ok(inviteService.listInviteRecords(UserContext.getUserId(), pageNum, pageSize));
|
||||
}
|
||||
|
||||
/** 邀请统计概览 */
|
||||
@GetMapping("/stats")
|
||||
public Result<InviteStatsVO> stats() {
|
||||
return Result.ok(inviteService.getInviteStats(UserContext.getUserId()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、配置参数
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
invite:
|
||||
inviter-points: 50 # 邀请人奖励积分
|
||||
invitee-points: 30 # 被邀请人奖励积分
|
||||
url-prefix: https://app.openclaw.com/invite/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、邀请流程说明
|
||||
|
||||
```
|
||||
邀请人 被邀请人 系统
|
||||
| | |
|
||||
| GET /invites/my-code |
|
||||
|--------------->| |
|
||||
|<-- InviteCodeVO(含邀请链接) |
|
||||
| | |
|
||||
| 分享邀请链接 | |
|
||||
|--------------->| |
|
||||
| | 注册成功 |
|
||||
| |-------------------->|
|
||||
| | POST /invites/bind |
|
||||
| |-------------------->|
|
||||
| | 校验邀请码 |
|
||||
| | 创建邀请记录 |
|
||||
| | 发放双方积分 |
|
||||
|<-- +50积分通知 |<-- +30积分通知 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
@@ -1,127 +0,0 @@
|
||||
# 管理后台开发文档 - Part 1(权限 + DTO/VO)
|
||||
|
||||
> 管理后台复用主应用 Service/Repository 层,新增 Admin Controller,路由前缀 `/api/admin`,通过角色拦截隔离。
|
||||
|
||||
## 一、角色常量
|
||||
|
||||
```java
|
||||
package com.openclaw.constant;
|
||||
|
||||
public interface AdminRole {
|
||||
String ADMIN = "ROLE_ADMIN"; // 超级管理员
|
||||
String OPERATOR = "ROLE_OPERATOR"; // 运营
|
||||
String AUDITOR = "ROLE_AUDITOR"; // 内容审核
|
||||
String FINANCE = "ROLE_FINANCE"; // 财务
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// SecurityConfig.java 追加
|
||||
http.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/admin/**")
|
||||
.hasAnyRole("ADMIN","OPERATOR","AUDITOR","FINANCE")
|
||||
);
|
||||
```
|
||||
|
||||
## 二、管理端 DTO
|
||||
|
||||
```java
|
||||
// AdminUserQueryDTO.java
|
||||
@Data
|
||||
public class AdminUserQueryDTO {
|
||||
private String keyword; // 手机号/昵称
|
||||
private String status; // active / banned
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 20;
|
||||
}
|
||||
|
||||
// AdminSkillQueryDTO.java
|
||||
@Data
|
||||
public class AdminSkillQueryDTO {
|
||||
private String keyword;
|
||||
private String status; // pending/approved/rejected/offline
|
||||
private Long categoryId;
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 20;
|
||||
}
|
||||
|
||||
// SkillAuditDTO.java
|
||||
@Data
|
||||
public class SkillAuditDTO {
|
||||
@NotNull private Long skillId;
|
||||
@NotBlank private String action; // approve / reject
|
||||
private String rejectReason;
|
||||
}
|
||||
|
||||
// AdminOrderQueryDTO.java
|
||||
@Data
|
||||
public class AdminOrderQueryDTO {
|
||||
private String keyword; // 订单号
|
||||
private String status;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 20;
|
||||
}
|
||||
|
||||
// AdjustPointsDTO.java
|
||||
@Data
|
||||
public class AdjustPointsDTO {
|
||||
@NotNull private Integer delta; // 正数增加,负数扣减
|
||||
private String remark;
|
||||
}
|
||||
|
||||
// RefundProcessDTO.java
|
||||
@Data
|
||||
public class RefundProcessDTO {
|
||||
@NotBlank private String action; // approve / reject
|
||||
private String remark;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、管理端 VO
|
||||
|
||||
```java
|
||||
// AdminUserVO.java
|
||||
@Data
|
||||
public class AdminUserVO {
|
||||
private Long id;
|
||||
private String phone, nickname, avatarUrl, status;
|
||||
private Integer totalPoints, frozenPoints;
|
||||
private LocalDateTime createdAt, lastLoginAt;
|
||||
}
|
||||
|
||||
// AdminSkillVO.java
|
||||
@Data
|
||||
public class AdminSkillVO {
|
||||
private Long id;
|
||||
private String name, coverImageUrl, status, rejectReason;
|
||||
private BigDecimal price;
|
||||
private Boolean isFree;
|
||||
private Long creatorId;
|
||||
private LocalDateTime createdAt, auditedAt;
|
||||
}
|
||||
|
||||
// AdminOrderVO.java
|
||||
@Data
|
||||
public class AdminOrderVO {
|
||||
private Long id;
|
||||
private String orderNo, status, paymentMethod;
|
||||
private Long userId;
|
||||
private BigDecimal totalAmount, cashAmount;
|
||||
private Integer pointsUsed;
|
||||
private LocalDateTime createdAt, paidAt;
|
||||
}
|
||||
|
||||
// DashboardVO.java
|
||||
@Data
|
||||
public class DashboardVO {
|
||||
private Long totalUsers, todayNewUsers, activeUsersLast7d;
|
||||
private BigDecimal totalRevenue, revenueToday;
|
||||
private Long totalOrders, ordersToday;
|
||||
private Long totalSkills, pendingAuditSkills, totalDownloads;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
@@ -1,272 +0,0 @@
|
||||
# 管理后台开发文档 - Part 2(AdminService 接口 + 实现)
|
||||
|
||||
## 一、AdminService 接口
|
||||
|
||||
```java
|
||||
package com.openclaw.service.admin;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.dto.admin.*;
|
||||
import com.openclaw.entity.PointsRule;
|
||||
import com.openclaw.vo.admin.*;
|
||||
import java.util.List;
|
||||
|
||||
public interface AdminService {
|
||||
// 看板
|
||||
DashboardVO getDashboard();
|
||||
|
||||
// 用户
|
||||
IPage<AdminUserVO> listUsers(AdminUserQueryDTO query);
|
||||
AdminUserVO getUserDetail(Long userId);
|
||||
void banUser(Long userId, String reason);
|
||||
void unbanUser(Long userId);
|
||||
void adjustPoints(Long userId, int delta, String remark);
|
||||
|
||||
// Skill 审核
|
||||
IPage<AdminSkillVO> listSkills(AdminSkillQueryDTO query);
|
||||
void auditSkill(SkillAuditDTO dto, Long auditorId);
|
||||
void offlineSkill(Long skillId, String reason);
|
||||
|
||||
// 订单 / 退款
|
||||
IPage<AdminOrderVO> listOrders(AdminOrderQueryDTO query);
|
||||
void processRefund(Long refundId, String action, String remark, Long operatorId);
|
||||
|
||||
// 积分规则
|
||||
List<PointsRule> listPointsRules();
|
||||
void updatePointsRule(Long ruleId, int points);
|
||||
}
|
||||
```
|
||||
|
||||
## 二、AdminServiceImpl.java
|
||||
|
||||
```java
|
||||
package com.openclaw.service.admin.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.openclaw.constant.ErrorCode;
|
||||
import com.openclaw.dto.admin.*;
|
||||
import com.openclaw.entity.*;
|
||||
import com.openclaw.exception.BusinessException;
|
||||
import com.openclaw.repository.*;
|
||||
import com.openclaw.service.PointsService;
|
||||
import com.openclaw.service.admin.AdminService;
|
||||
import com.openclaw.vo.admin.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminServiceImpl implements AdminService {
|
||||
|
||||
private final UserRepository userRepo;
|
||||
private final SkillRepository skillRepo;
|
||||
private final OrderRepository orderRepo;
|
||||
private final OrderRefundRepository refundRepo;
|
||||
private final PointsRuleRepository pointsRuleRepo;
|
||||
private final SkillDownloadRepository downloadRepo;
|
||||
private final PointsService pointsService;
|
||||
|
||||
// ---------- 看板 ----------
|
||||
|
||||
@Override
|
||||
public DashboardVO getDashboard() {
|
||||
DashboardVO vo = new DashboardVO();
|
||||
LocalDateTime dayStart = LocalDate.now().atStartOfDay();
|
||||
|
||||
vo.setTotalUsers(userRepo.selectCount(null));
|
||||
vo.setTodayNewUsers(userRepo.selectCount(
|
||||
new LambdaQueryWrapper<User>().ge(User::getCreatedAt, dayStart)));
|
||||
vo.setActiveUsersLast7d(
|
||||
userRepo.countActiveUsersAfter(LocalDateTime.now().minusDays(7)));
|
||||
|
||||
vo.setTotalOrders(orderRepo.selectCount(null));
|
||||
vo.setOrdersToday(orderRepo.selectCount(
|
||||
new LambdaQueryWrapper<Order>().ge(Order::getCreatedAt, dayStart)));
|
||||
|
||||
BigDecimal rev = orderRepo.sumCashAmount("paid");
|
||||
vo.setTotalRevenue(rev == null ? BigDecimal.ZERO : rev);
|
||||
BigDecimal revToday = orderRepo.sumCashAmountAfter("paid", dayStart);
|
||||
vo.setRevenueToday(revToday == null ? BigDecimal.ZERO : revToday);
|
||||
|
||||
vo.setTotalSkills(skillRepo.selectCount(null));
|
||||
vo.setPendingAuditSkills(skillRepo.selectCount(
|
||||
new LambdaQueryWrapper<Skill>().eq(Skill::getStatus, "pending")));
|
||||
vo.setTotalDownloads(downloadRepo.selectCount(null));
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ---------- 用户 ----------
|
||||
|
||||
@Override
|
||||
public IPage<AdminUserVO> listUsers(AdminUserQueryDTO q) {
|
||||
return userRepo.selectPage(new Page<>(q.getPageNum(), q.getPageSize()),
|
||||
new LambdaQueryWrapper<User>()
|
||||
.and(q.getKeyword() != null, w -> w
|
||||
.like(User::getNickname, q.getKeyword()).or()
|
||||
.like(User::getPhone, q.getKeyword()))
|
||||
.eq(q.getStatus() != null, User::getStatus, q.getStatus())
|
||||
.orderByDesc(User::getCreatedAt)
|
||||
).convert(this::toUserVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminUserVO getUserDetail(Long userId) {
|
||||
User u = userRepo.selectById(userId);
|
||||
if (u == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
return toUserVO(u);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void banUser(Long userId, String reason) {
|
||||
User u = requireUser(userId);
|
||||
u.setStatus("banned"); u.setBanReason(reason);
|
||||
userRepo.updateById(u);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void unbanUser(Long userId) {
|
||||
User u = requireUser(userId);
|
||||
u.setStatus("active"); u.setBanReason(null);
|
||||
userRepo.updateById(u);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void adjustPoints(Long userId, int delta, String remark) {
|
||||
String type = delta > 0 ? "admin_add" : "admin_deduct";
|
||||
String desc = remark != null ? remark : (delta > 0 ? "管理员补积分" : "管理员扣积分");
|
||||
pointsService.addPointsDirectly(userId, Math.abs(delta), type, null, desc);
|
||||
}
|
||||
|
||||
// ---------- Skill ----------
|
||||
|
||||
@Override
|
||||
public IPage<AdminSkillVO> listSkills(AdminSkillQueryDTO q) {
|
||||
return skillRepo.selectPage(new Page<>(q.getPageNum(), q.getPageSize()),
|
||||
new LambdaQueryWrapper<Skill>()
|
||||
.like(q.getKeyword() != null, Skill::getName, q.getKeyword())
|
||||
.eq(q.getStatus() != null, Skill::getStatus, q.getStatus())
|
||||
.eq(q.getCategoryId() != null, Skill::getCategoryId, q.getCategoryId())
|
||||
.orderByDesc(Skill::getCreatedAt)
|
||||
).convert(this::toSkillVO);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void auditSkill(SkillAuditDTO dto, Long auditorId) {
|
||||
Skill s = skillRepo.selectById(dto.getSkillId());
|
||||
if (s == null) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
if (!"pending".equals(s.getStatus())) throw new BusinessException(ErrorCode.SKILL_STATUS_ERROR);
|
||||
switch (dto.getAction()) {
|
||||
case "approve" -> s.setStatus("approved");
|
||||
case "reject" -> { s.setStatus("rejected"); s.setRejectReason(dto.getRejectReason()); }
|
||||
default -> throw new BusinessException(ErrorCode.PARAM_ERROR);
|
||||
}
|
||||
s.setAuditorId(auditorId);
|
||||
s.setAuditedAt(LocalDateTime.now());
|
||||
skillRepo.updateById(s);
|
||||
log.info("Skill审核 id={} action={} auditor={}", dto.getSkillId(), dto.getAction(), auditorId);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void offlineSkill(Long skillId, String reason) {
|
||||
Skill s = skillRepo.selectById(skillId);
|
||||
if (s == null) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||
s.setStatus("offline"); s.setRejectReason(reason);
|
||||
skillRepo.updateById(s);
|
||||
}
|
||||
|
||||
// ---------- 订单 ----------
|
||||
|
||||
@Override
|
||||
public IPage<AdminOrderVO> listOrders(AdminOrderQueryDTO q) {
|
||||
return orderRepo.selectPage(new Page<>(q.getPageNum(), q.getPageSize()),
|
||||
new LambdaQueryWrapper<Order>()
|
||||
.like(q.getKeyword() != null, Order::getOrderNo, q.getKeyword())
|
||||
.eq(q.getStatus() != null, Order::getStatus, q.getStatus())
|
||||
.ge(q.getStartDate() != null, Order::getCreatedAt, q.getStartDate() != null ? q.getStartDate().atStartOfDay() : null)
|
||||
.le(q.getEndDate() != null, Order::getCreatedAt, q.getEndDate() != null ? q.getEndDate().plusDays(1).atStartOfDay() : null)
|
||||
.orderByDesc(Order::getCreatedAt)
|
||||
).convert(this::toOrderVO);
|
||||
}
|
||||
|
||||
@Override @Transactional
|
||||
public void processRefund(Long refundId, String action, String remark, Long operatorId) {
|
||||
OrderRefund rf = refundRepo.selectById(refundId);
|
||||
if (rf == null) throw new BusinessException(ErrorCode.REFUND_NOT_FOUND);
|
||||
if (!"pending".equals(rf.getStatus())) throw new BusinessException(ErrorCode.REFUND_STATUS_ERROR);
|
||||
Order o = orderRepo.selectById(rf.getOrderId());
|
||||
switch (action) {
|
||||
case "approve" -> {
|
||||
rf.setStatus("approved"); o.setStatus("refunded");
|
||||
if (rf.getRefundPoints() != null && rf.getRefundPoints() > 0)
|
||||
pointsService.addPointsDirectly(
|
||||
o.getUserId(), rf.getRefundPoints(), "refund", rf.getId(), "退款返还积分");
|
||||
// TODO: 调用支付渠道退款
|
||||
}
|
||||
case "reject" -> { rf.setStatus("rejected"); o.setStatus("paid"); }
|
||||
default -> throw new BusinessException(ErrorCode.PARAM_ERROR);
|
||||
}
|
||||
rf.setRemark(remark); rf.setOperatorId(operatorId); rf.setProcessedAt(LocalDateTime.now());
|
||||
refundRepo.updateById(rf); orderRepo.updateById(o);
|
||||
}
|
||||
|
||||
// ---------- 积分规则 ----------
|
||||
|
||||
@Override
|
||||
public List<PointsRule> listPointsRules() { return pointsRuleRepo.selectList(null); }
|
||||
|
||||
@Override @Transactional
|
||||
public void updatePointsRule(Long ruleId, int points) {
|
||||
PointsRule r = pointsRuleRepo.selectById(ruleId);
|
||||
if (r == null) throw new BusinessException(ErrorCode.POINTS_RULE_NOT_FOUND);
|
||||
r.setPoints(points); pointsRuleRepo.updateById(r);
|
||||
}
|
||||
|
||||
// ---------- 私有辅助 ----------
|
||||
|
||||
private User requireUser(Long id) {
|
||||
User u = userRepo.selectById(id);
|
||||
if (u == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
return u;
|
||||
}
|
||||
|
||||
private AdminUserVO toUserVO(User u) {
|
||||
AdminUserVO vo = new AdminUserVO();
|
||||
vo.setId(u.getId()); vo.setPhone(u.getPhone());
|
||||
vo.setNickname(u.getNickname()); vo.setAvatarUrl(u.getAvatarUrl());
|
||||
vo.setStatus(u.getStatus()); vo.setCreatedAt(u.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private AdminSkillVO toSkillVO(Skill s) {
|
||||
AdminSkillVO vo = new AdminSkillVO();
|
||||
vo.setId(s.getId()); vo.setName(s.getName());
|
||||
vo.setCoverImageUrl(s.getCoverImageUrl()); vo.setPrice(s.getPrice());
|
||||
vo.setIsFree(s.getIsFree()); vo.setStatus(s.getStatus());
|
||||
vo.setCreatorId(s.getCreatorId()); vo.setCreatedAt(s.getCreatedAt());
|
||||
vo.setAuditedAt(s.getAuditedAt()); vo.setRejectReason(s.getRejectReason());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private AdminOrderVO toOrderVO(Order o) {
|
||||
AdminOrderVO vo = new AdminOrderVO();
|
||||
vo.setId(o.getId()); vo.setOrderNo(o.getOrderNo());
|
||||
vo.setUserId(o.getUserId()); vo.setTotalAmount(o.getTotalAmount());
|
||||
vo.setCashAmount(o.getCashAmount()); vo.setPointsUsed(o.getPointsUsed());
|
||||
vo.setStatus(o.getStatus()); vo.setPaymentMethod(o.getPaymentMethod());
|
||||
vo.setCreatedAt(o.getCreatedAt()); vo.setPaidAt(o.getPaidAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
@@ -1,157 +0,0 @@
|
||||
# 管理后台开发文档 - Part 3(AdminController)
|
||||
|
||||
## AdminController.java
|
||||
|
||||
```java
|
||||
package com.openclaw.controller.admin;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.openclaw.common.Result;
|
||||
import com.openclaw.dto.admin.*;
|
||||
import com.openclaw.entity.PointsRule;
|
||||
import com.openclaw.service.admin.AdminService;
|
||||
import com.openclaw.util.UserContext;
|
||||
import com.openclaw.vo.admin.*;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final AdminService adminService;
|
||||
|
||||
// ==================== 数据看板 ====================
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<DashboardVO> dashboard() {
|
||||
return Result.ok(adminService.getDashboard());
|
||||
}
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
@GetMapping("/users")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<IPage<AdminUserVO>> listUsers(AdminUserQueryDTO query) {
|
||||
return Result.ok(adminService.listUsers(query));
|
||||
}
|
||||
|
||||
@GetMapping("/users/{userId}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<AdminUserVO> getUser(@PathVariable Long userId) {
|
||||
return Result.ok(adminService.getUserDetail(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/users/{userId}/ban")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Result<Void> banUser(
|
||||
@PathVariable Long userId,
|
||||
@RequestParam(required = false) String reason) {
|
||||
adminService.banUser(userId, reason);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/users/{userId}/unban")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Result<Void> unbanUser(@PathVariable Long userId) {
|
||||
adminService.unbanUser(userId);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/users/{userId}/points")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Result<Void> adjustPoints(
|
||||
@PathVariable Long userId,
|
||||
@Valid @RequestBody AdjustPointsDTO dto) {
|
||||
adminService.adjustPoints(userId, dto.getDelta(), dto.getRemark());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
// ==================== Skill 审核 ====================
|
||||
|
||||
@GetMapping("/skills")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR','AUDITOR')")
|
||||
public Result<IPage<AdminSkillVO>> listSkills(AdminSkillQueryDTO query) {
|
||||
return Result.ok(adminService.listSkills(query));
|
||||
}
|
||||
|
||||
@PostMapping("/skills/audit")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','AUDITOR')")
|
||||
public Result<Void> auditSkill(@Valid @RequestBody SkillAuditDTO dto) {
|
||||
adminService.auditSkill(dto, UserContext.getUserId());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/skills/{skillId}/offline")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<Void> offlineSkill(
|
||||
@PathVariable Long skillId,
|
||||
@RequestParam(required = false) String reason) {
|
||||
adminService.offlineSkill(skillId, reason);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
// ==================== 订单管理 ====================
|
||||
|
||||
@GetMapping("/orders")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR','FINANCE')")
|
||||
public Result<IPage<AdminOrderVO>> listOrders(AdminOrderQueryDTO query) {
|
||||
return Result.ok(adminService.listOrders(query));
|
||||
}
|
||||
|
||||
@PostMapping("/refunds/{refundId}/process")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','FINANCE')")
|
||||
public Result<Void> processRefund(
|
||||
@PathVariable Long refundId,
|
||||
@Valid @RequestBody RefundProcessDTO dto) {
|
||||
adminService.processRefund(
|
||||
refundId, dto.getAction(), dto.getRemark(), UserContext.getUserId());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
// ==================== 积分规则 ====================
|
||||
|
||||
@GetMapping("/points-rules")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public Result<List<PointsRule>> listRules() {
|
||||
return Result.ok(adminService.listPointsRules());
|
||||
}
|
||||
|
||||
@PutMapping("/points-rules/{ruleId}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Result<Void> updateRule(
|
||||
@PathVariable Long ruleId,
|
||||
@RequestParam int points) {
|
||||
adminService.updatePointsRule(ruleId, points);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 汇总
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /api/admin/dashboard | 数据看板 | ADMIN/OPERATOR |
|
||||
| GET | /api/admin/users | 用户列表 | ADMIN/OPERATOR |
|
||||
| GET | /api/admin/users/{id} | 用户详情 | ADMIN/OPERATOR |
|
||||
| POST | /api/admin/users/{id}/ban | 封禁用户 | ADMIN |
|
||||
| POST | /api/admin/users/{id}/unban | 解封用户 | ADMIN |
|
||||
| POST | /api/admin/users/{id}/points | 调整积分 | ADMIN |
|
||||
| GET | /api/admin/skills | Skill列表 | ADMIN/OPERATOR/AUDITOR |
|
||||
| POST | /api/admin/skills/audit | Skill审核 | ADMIN/AUDITOR |
|
||||
| POST | /api/admin/skills/{id}/offline | Skill下架 | ADMIN/OPERATOR |
|
||||
| GET | /api/admin/orders | 订单列表 | ADMIN/OPERATOR/FINANCE |
|
||||
| POST | /api/admin/refunds/{id}/process | 处理退款 | ADMIN/FINANCE |
|
||||
| GET | /api/admin/points-rules | 积分规则列表 | ADMIN/OPERATOR |
|
||||
| PUT | /api/admin/points-rules/{id} | 更新积分规则 | ADMIN |
|
||||
|
||||
---
|
||||
**文档版本**:v1.0 | **创建日期**:2026-03-16
|
||||
Reference in New Issue
Block a user