feat: 全量更新前后端代码及文档 - 社区/定制/优惠券/活动/会员等模块

This commit is contained in:
Developer
2026-03-21 18:35:41 +08:00
parent a8aaf15bfb
commit 942465b758
590 changed files with 27840 additions and 14720 deletions

View File

@@ -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代码已全部实现请直接参阅源码。

View File

@@ -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;
}
}
```

View File

@@ -1,374 +0,0 @@
# 用户服务开发文档 - Part 2Controller + 通用工具)
## 五、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.javaJWT认证拦截器
```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

View File

@@ -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

View 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 转 100B 收到 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 冻结与解冻的幂等性
所有冻结/解冻操作必须带业务IDorderId/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 冻结积分不参与过期
被冻结的积分在冻结期间不应触发过期(过期定时任务需排除冻结状态的批次)。如果业务解冻后发现原批次已过期,应创建新批次并设合理有效期。
---
*本文档随功能开发进度持续更新*

View 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)双重保障
- **优惠叠加控制**: 默认活动价+优惠券可叠加,但需配置互斥开关
### 性能
- **热点数据缓存**: 活动列表/活动价/券库存走RedisDB只做持久化
- **秒杀异步**: 下单请求先入MQ异步消费创建订单前端轮询结果
- **批量操作**: 券过期/活动状态更新分批执行(每批1000条)
### 一致性
- **券库存**: Redis预扣 → DB写入 → 失败回补Redis最终一致
- **活动库存**: Redis + DB双写定时任务校正偏差
- **退款退券**: 事务内同步处理(券退回 + 库存回补)
### 与现有系统集成
- **会员折扣**: 会员等级折扣与活动价/优惠券的优先级: 活动价 > 优惠券 > 会员折扣
- **积分抵扣**: 积分在最终实付金额(扣除活动优惠和券优惠后)上计算
- **MQ补偿**: 复用现有 CompensationService秒杀/券相关MQ失败自动写入补偿表
---
*本文档将随开发进度持续更新*

View File

@@ -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

View File

@@ -1,238 +0,0 @@
# 订单服务开发文档 - Part 1Entity + 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

View File

@@ -1,288 +0,0 @@
# 订单服务开发文档 - Part 2Service实现 + 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,272 +0,0 @@
# 管理后台开发文档 - Part 2AdminService 接口 + 实现)
## 一、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

View File

@@ -1,157 +0,0 @@
# 管理后台开发文档 - Part 3AdminController
## 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