Initial commit
This commit is contained in:
432
后端架构设计/05-Skill服务开发文档.md
Normal file
432
后端架构设计/05-Skill服务开发文档.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user