433 lines
13 KiB
Markdown
433 lines
13 KiB
Markdown
# 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
|