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

13 KiB
Raw Blame History

Skill服务开发文档

一、Entity 实体类

Skill.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

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

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列表查询参数

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

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

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

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

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

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

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