# 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 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 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 listSkills(SkillQueryDTO query, Long currentUserId) { Page page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper 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 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() .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> listSkills(SkillQueryDTO query) { Long userId = UserContext.getUserId(); // 未登录为null return Result.ok(skillService.listSkills(query, userId)); } /** Skill详情(公开) */ @GetMapping("/{id}") public Result getDetail(@PathVariable Long id) { return Result.ok(skillService.getSkillDetail(id, UserContext.getUserId())); } /** 上传Skill(需登录) */ @PostMapping public Result createSkill(@Valid @RequestBody SkillCreateDTO dto) { return Result.ok(skillService.createSkill(UserContext.getUserId(), dto)); } /** 发表评价(需登录且已拥有) */ @PostMapping("/{id}/reviews") public Result submitReview( @PathVariable Long id, @Valid @RequestBody SkillReviewDTO dto) { skillService.submitReview(id, UserContext.getUserId(), dto); return Result.ok(); } } ``` --- **文档版本**:v1.0 **创建日期**:2026-03-16