Files
number/后端架构设计/05-Skill服务开发文档.md
2026-03-17 12:09:43 +08:00

433 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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