feat(account): 实现账户积分系统及套餐功能

- 将账户余额系统改造为积分系统,充值金额按1元=100积分计算
- 新增套餐配置功能,支持套餐购买并获取基础额度和赠送额度
- 在账户冻结功能中集成模型价格计算,根据预估tokens自动计算冻结金额
- 更新支付流程以支持套餐ID关联和积分计算
- 修改全局异常处理器返回格式,统一使用CommonResult
- 优化账户交易记录的备注信息显示
- 添加雪花算法配置用于分布式ID生成
- 扩展账户冻结DTO添加预估tokens字段
- 重构账户服务中的金额处理逻辑为积分处理逻辑
- 实现套餐配置的CRUD操作接口和相关实体类
- 更新支付回调逻辑以正确处理套餐购买场景
This commit is contained in:
wangzhiwei 2026-04-11 21:11:53 +08:00
parent 51fce1ece6
commit e651e73fa2
18 changed files with 835 additions and 856 deletions

View File

@ -47,6 +47,15 @@ public class CommonResult<T> {
/**
* 成功返回结果
*
* @param data 获取的数据
*/
public static <T> CommonResult<T> success(IErrorCode errorCode,T data) {
return new CommonResult<T>(errorCode.getCode(), errorCode.getMessage(), data);
}
/**
* 成功返回结果
*
* @param errorCode 获取的编码
* @param data 获取的数据
* @param message 提示信息
@ -58,11 +67,11 @@ public class CommonResult<T> {
/**
* 成功返回结果
*
* @param errorCode 获取的数据
* @param code 获取的数据
* @param message 提示信息
*/
public static <T> CommonResult<T> success(IErrorCode errorCode, String message ) {
return new CommonResult<T>(errorCode.getCode(), message,null);
public static <T> CommonResult<T> success(Long code, String message ) {
return new CommonResult<T>(code, message,null);
}
/**

View File

@ -1,5 +1,6 @@
package com.kexue.skills.controller;
import com.kexue.skills.common.CommonResult;
import com.kexue.skills.entity.AccountFrozen;
import com.kexue.skills.entity.dto.AccountFrozenDto;
import com.kexue.skills.entity.dto.AccountReleaseDto;
@ -7,12 +8,16 @@ import com.kexue.skills.service.AccountFrozenService;
import com.kexue.skills.common.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.io.IOException;
/**
* 账户冻结单控制器
@ -25,6 +30,9 @@ import javax.annotation.Resource;
@Tag(name = "账户冻结单", description = "账户冻结单管理接口")
public class AccountFrozenController {
private static final Logger logger = LoggerFactory.getLogger(AccountFrozenController.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
@Resource
private AccountFrozenService accountFrozenService;
@ -35,9 +43,14 @@ public class AccountFrozenController {
*/
@PostMapping("/frozen")
@Operation(summary = "创建冻结单", description = "创建账户冻结单")
public Result<AccountFrozen> createFrozen(@RequestBody AccountFrozenDto accountFrozenDto) {
public CommonResult<AccountFrozen> createFrozen(@RequestBody AccountFrozenDto accountFrozenDto) {
try {
logger.info("创建冻结单入参: {}", objectMapper.writeValueAsString(accountFrozenDto));
} catch (IOException e) {
logger.error("创建冻结单入参序列化失败", e);
}
AccountFrozen accountFrozen = accountFrozenService.createFrozen(accountFrozenDto);
return new Result<AccountFrozen>().ok().data(accountFrozen);
return CommonResult.success(accountFrozen);
}
/**
@ -47,9 +60,14 @@ public class AccountFrozenController {
*/
@PostMapping("/release")
@Operation(summary = "释放冻结单", description = "释放账户冻结单")
public Result<AccountFrozen> releaseFrozen(@RequestBody AccountReleaseDto accountReleaseDto) {
public CommonResult<AccountFrozen> releaseFrozen(@RequestBody AccountReleaseDto accountReleaseDto) {
try {
logger.info("释放冻结单入参: {}", objectMapper.writeValueAsString(accountReleaseDto));
} catch (IOException e) {
logger.error("释放冻结单入参序列化失败", e);
}
AccountFrozen accountFrozen = accountFrozenService.releaseFrozen(accountReleaseDto);
return new Result<AccountFrozen>().ok().data(accountFrozen);
return CommonResult.success(accountFrozen);
}
}

View File

@ -0,0 +1,121 @@
package com.kexue.skills.controller;
import com.github.pagehelper.PageInfo;
import com.kexue.skills.annotation.RequireAuth;
import com.kexue.skills.common.CommonResult;
import com.kexue.skills.entity.PackageConfig;
import com.kexue.skills.entity.base.IdDto;
import com.kexue.skills.entity.dto.PackageConfigDto;
import com.kexue.skills.service.PackageConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
/**
* (PackageConfig)表控制层
*
* @author 系统生成
* @since 2026-04-11
*/
@RestController
@RequestMapping("api/packageConfig")
@Tag(name = "套餐配置管理 Api")
@CrossOrigin(origins = "*")
public class PackageConfigController {
/**
* 服务对象
*/
@Resource
private PackageConfigService packageConfigService;
/**
* 分页查询
*
* @param queryDto 筛选条件
* @return 查询结果
*/
@PostMapping("/getPageList")
@Operation(summary = "查询分页列表", description = "查询分页列表")
public CommonResult<PageInfo<PackageConfig>> getPageList(@RequestBody PackageConfigDto queryDto) {
return CommonResult.success(packageConfigService.getPageList(queryDto));
}
/**
* 查询列表
*
* @param queryDto 筛选条件
* @return 查询结果
*/
@PostMapping("/getList")
@Operation(summary = "查询列表", description = "查询列表")
public CommonResult<PageInfo<PackageConfig>> getList(@RequestBody PackageConfigDto queryDto) {
return CommonResult.success(new PageInfo<>(packageConfigService.getList(queryDto)));
}
/**
* 通过主键查询单条数据
*
* @param id 主键
* @return 单条数据
*/
@PostMapping("queryById/{id}")
@Operation(summary = "通过ID查询套餐", description = "通过ID查询套餐")
public CommonResult<PackageConfig> queryById(@PathVariable("id") Long id) {
return CommonResult.success(packageConfigService.queryById(id));
}
/**
* 新增数据
*
* @param packageConfig 实体
* @return 新增结果
*/
@PostMapping("/insert")
@Operation(summary = "新增套餐", description = "新增套餐")
@RequireAuth
public CommonResult<PackageConfig> insert(@RequestBody PackageConfig packageConfig) {
return CommonResult.success(packageConfigService.insert(packageConfig));
}
/**
* 编辑数据
*
* @param packageConfig 实体
* @return 编辑结果
*/
@PostMapping("/update")
@Operation(summary = "更新套餐", description = "更新套餐")
@RequireAuth
public CommonResult<PackageConfig> update(@RequestBody PackageConfig packageConfig) {
return CommonResult.success(packageConfigService.update(packageConfig));
}
/**
* 通过主键逻辑删除
*
* @param idDto 主键
* @return 删除数据
*/
@PostMapping("/logicDeleteById")
@Operation(summary = "逻辑删除套餐", description = "逻辑删除套餐")
@RequireAuth
public CommonResult<Boolean> logicDeleteById(@RequestBody IdDto idDto) {
return CommonResult.success(packageConfigService.logicDeleteById(idDto.getId(), "admin") > 0);
}
/**
* 删除数据
*
* @param id 主键
* @return 删除数据
*/
@PostMapping("deleteById/{id}")
@Operation(summary = "物理删除套餐", description = "物理删除套餐")
@RequireAuth
public CommonResult<Boolean> deleteById(@PathVariable("id") Long id) {
return CommonResult.success(packageConfigService.deleteById(id) > 0);
}
}

View File

@ -0,0 +1,43 @@
package com.kexue.skills.entity;
import java.io.Serializable;
import com.kexue.skills.entity.base.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* (PackageConfig)实体类
* 套餐配置表
*
* @author 系统生成
* @since 2026-04-11
*/
@Data
public class PackageConfig extends BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description ="套餐ID")
private Long id;
@Schema(description ="套餐名称")
private String name;
@Schema(description ="价格")
private BigDecimal price;
@Schema(description ="基础额度")
private BigDecimal baseAmount;
@Schema(description ="赠送额度")
private BigDecimal giftAmount;
@Schema(description ="创建时间")
private Date createTime;
@Schema(description ="更新时间")
private Date updateTime;
}

View File

@ -56,7 +56,7 @@ public class PaymentOrder extends BaseEntity implements Serializable {
@Schema(description ="商品描述")
private String productDesc;
@Schema(description ="关联业务ID")
@Schema(description ="关联业务ID比如packageId套餐ID")
private Long businessId;
@Schema(description ="业务类型recharge,purchase_content")

View File

@ -30,6 +30,12 @@ public class AccountFrozenDto {
@Schema(description ="冻结类型1余额 2图片张数 3时间 4次数 5积分 99其他")
private Integer frozenType;
@Schema(description ="预估输入tokens")
private Long estimatedInputTokens;
@Schema(description ="预估输出tokens")
private Long estimatedOutputTokens;
@Schema(description ="过期时间")
private Date expireAt;

View File

@ -0,0 +1,27 @@
package com.kexue.skills.entity.dto;
import com.kexue.skills.entity.base.BaseQueryDto;
import lombok.Data;
import java.math.BigDecimal;
/**
* (PackageConfig)查询DTO类
*
* @author 系统生成
* @since 2026-04-11
*/
@Data
public class PackageConfigDto extends BaseQueryDto {
private Long id;
private String name;
private BigDecimal price;
private BigDecimal baseAmount;
private BigDecimal giftAmount;
}

View File

@ -30,4 +30,6 @@ public class PaymentOrderDto extends BaseQueryDto {
private Integer deleteFlag;
private Long packageId;
}

View File

@ -15,9 +15,9 @@ public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public CommonResult<String> handleBizException(BizException e) {
if (e == null) {
return CommonResult.failed("未知错误");
return CommonResult.success("未知错误");
}
return CommonResult.failed(e.getMessage());
return CommonResult.success(e.getErrorCode(), e.getMessage());
}
// 其他异常处理...

View File

@ -0,0 +1,66 @@
package com.kexue.skills.mapper;
import com.kexue.skills.entity.PackageConfig;
import com.kexue.skills.entity.dto.PackageConfigDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* (PackageConfig)表数据库访问层
*
* @author 系统生成
* @since 2026-04-11
*/
@Mapper
public interface PackageConfigMapper {
/**
* 分页查询
*
* @param queryDto 筛选条件
* @return 查询结果
*/
List<PackageConfig> getPageList(PackageConfigDto queryDto);
/**
* 查询列表
*
* @param queryDto 筛选条件
* @return 查询结果
*/
List<PackageConfig> getList(PackageConfigDto queryDto);
/**
* 通过主键查询单条数据
*
* @param id 主键
* @return 实例对象
*/
PackageConfig queryById(Long id);
/**
* 新增数据
*
* @param packageConfig 实例对象
* @return 影响行数
*/
int insert(PackageConfig packageConfig);
/**
* 更新数据
*
* @param packageConfig 实例对象
* @return 影响行数
*/
int update(PackageConfig packageConfig);
/**
* 通过主键删除
*
* @param id 主键
* @return 影响行数
*/
int deleteById(Long id);
}

View File

@ -0,0 +1,73 @@
package com.kexue.skills.service;
import com.github.pagehelper.PageInfo;
import com.kexue.skills.entity.PackageConfig;
import com.kexue.skills.entity.dto.PackageConfigDto;
import java.util.List;
/**
* (PackageConfig)表服务接口
*
* @author 系统生成
* @since 2026-04-11
*/
public interface PackageConfigService extends BaseService {
/**
* 分页查询
*
* @param queryDto 筛选条件
* @return 查询结果
*/
PageInfo<PackageConfig> getPageList(PackageConfigDto queryDto);
/**
* 查询列表
*
* @param queryDto 筛选条件
* @return 查询结果
*/
List<PackageConfig> getList(PackageConfigDto queryDto);
/**
* 通过主键查询单条数据
*
* @param id 主键
* @return 实例对象
*/
PackageConfig queryById(Long id);
/**
* 新增数据
*
* @param packageConfig 实例对象
* @return 实例对象
*/
PackageConfig insert(PackageConfig packageConfig);
/**
* 更新数据
*
* @param packageConfig 实例对象
* @return 实例对象
*/
PackageConfig update(PackageConfig packageConfig);
/**
* 通过主键逻辑删除
*
* @param id 主键
* @param updateBy 更新人
* @return 影响行数
*/
int logicDeleteById(Long id, String updateBy);
/**
* 通过主键物理删除
*
* @param id 主键
* @return 影响行数
*/
int deleteById(Long id);
}

View File

@ -62,9 +62,6 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
if (accountFrozenDto.getSessionId() == null) {
throw new BizException(ResultCode.SESSION_ID_NOT_EXIST.getCode(), ResultCode.SESSION_ID_NOT_EXIST.getMessage());
}
if (accountFrozenDto.getFrozenAmount() == null || accountFrozenDto.getFrozenAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new BizException(ResultCode.PARAMETER_EMPTY.getCode(), "冻结金额必须大于0");
}
if (accountFrozenDto.getFrozenType() == null) {
throw new BizException(ResultCode.PARAMETER_EMPTY.getCode(), "冻结类型不能为空");
}
@ -82,16 +79,47 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
throw new BizException(ResultCode.ACCOUNT_NOT_EXIST.getCode(), ResultCode.ACCOUNT_NOT_EXIST.getMessage());
}
// 4. 检查余额是否足够账户总余额 - 已冻结金额 >= 本次冻结金额
// 4. 当冻结类型为余额时根据预估tokens计算冻结金额
BigDecimal finalFrozenAmount = accountFrozenDto.getFrozenAmount();
if (accountFrozenDto.getFrozenType() != null && accountFrozenDto.getFrozenType() == 1) {
if (accountFrozenDto.getEstimatedInputTokens() != null &&
accountFrozenDto.getEstimatedOutputTokens() != null &&
accountFrozenDto.getModelName() != null) {
// 查询模型价格信息
ModelPrice modelPrice = modelPriceService.queryByModelName(accountFrozenDto.getModelName());
if (modelPrice != null) {
// 计算token费用
long inputFee = accountFrozenDto.getEstimatedInputTokens() / modelPrice.getInputPerCent();
if (accountFrozenDto.getEstimatedInputTokens() % modelPrice.getInputPerCent() > 0) {
inputFee += 1;
}
long outputFee = accountFrozenDto.getEstimatedOutputTokens() / modelPrice.getOutputPerCent();
if (accountFrozenDto.getEstimatedOutputTokens() % modelPrice.getOutputPerCent() > 0) {
outputFee += 1;
}
// 总费用
long totalFee = inputFee + outputFee;
// 转换为元
BigDecimal feeInYuan = BigDecimal.valueOf(totalFee).divide(BigDecimal.valueOf(100));
// 转换为积分1元=100积分
finalFrozenAmount = feeInYuan.multiply(BigDecimal.valueOf(100));
}
}
}
// 5. 检查余额是否足够账户总余额 - 已冻结金额 >= 本次冻结金额
BigDecimal balance = account.getBalance() == null ? BigDecimal.ZERO : account.getBalance();
BigDecimal frozenAmount = account.getFrozenAmount() == null ? BigDecimal.ZERO : account.getFrozenAmount();
BigDecimal availableBalance = balance.subtract(frozenAmount);
if (availableBalance.compareTo(accountFrozenDto.getFrozenAmount()) < 0) {
if (availableBalance.compareTo(finalFrozenAmount) < 0) {
throw new BizException(ResultCode.INSUFFICIENT_BALANCE.getCode(), ResultCode.INSUFFICIENT_BALANCE.getMessage());
}
// 5. 更新账户冻结金额
account.setFrozenAmount(frozenAmount.add(accountFrozenDto.getFrozenAmount()));
account.setFrozenAmount(frozenAmount.add(finalFrozenAmount));
account.setUpdateTime(new Date());
accountMapper.update(account);
@ -102,7 +130,7 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
accountFrozen.setSessionId(accountFrozenDto.getSessionId());
accountFrozen.setCallId(accountFrozenDto.getCallId());
accountFrozen.setModelName(accountFrozenDto.getModelName());
accountFrozen.setFrozenAmount(accountFrozenDto.getFrozenAmount());
accountFrozen.setFrozenAmount(finalFrozenAmount);
accountFrozen.setFrozenType(accountFrozenDto.getFrozenType());
accountFrozen.setStatus("RESERVED");
accountFrozen.setExpireAt(accountFrozenDto.getExpireAt());
@ -173,7 +201,9 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
// 总费用
long totalFee = inputFee + outputFee;
// 转换为元
finalAmount = BigDecimal.valueOf(totalFee).divide(BigDecimal.valueOf(100));
BigDecimal feeInYuan = BigDecimal.valueOf(totalFee).divide(BigDecimal.valueOf(100));
// 转换为积分1元=100积分
finalAmount = feeInYuan.multiply(BigDecimal.valueOf(100));
}
}
}
@ -188,11 +218,33 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
// 如果需要扣减余额
if (finalAmount.compareTo(BigDecimal.ZERO) > 0) {
// 检查实际扣减是否大于预扣减
if (finalAmount.compareTo(accountFrozen.getFrozenAmount()) > 0) {
// 实际扣减大于预扣减根据实际扣减进行扣除
// 如果余额不够实际扣减将balance设置为0
if (balance.compareTo(finalAmount) < 0) {
throw new BizException(ResultCode.INSUFFICIENT_BALANCE.getCode(), ResultCode.INSUFFICIENT_BALANCE.getMessage());
}
account.setBalance(BigDecimal.ZERO);
} else {
account.setBalance(balance.subtract(finalAmount));
}
} else {
// 实际扣减小于等于预扣减正常扣减
if (balance.compareTo(finalAmount) < 0) {
account.setBalance(BigDecimal.ZERO);
} else {
account.setBalance(balance.subtract(finalAmount));
}
// 实际扣减小于预扣减将剩余预扣减加回balance
if (finalAmount.compareTo(accountFrozen.getFrozenAmount()) < 0) {
BigDecimal remainingFrozen = accountFrozen.getFrozenAmount().subtract(finalAmount);
account.setBalance(account.getBalance().add(remainingFrozen));
}
}
} else {
// 最终扣减为0将预扣减全部加回balance
account.setBalance(balance.add(accountFrozen.getFrozenAmount()));
}
account.setUpdateTime(new Date());
accountMapper.update(account);

View File

@ -14,7 +14,9 @@ import com.kexue.skills.mapper.AccountTransactionMapper;
import com.kexue.skills.mapper.SysUserMapper;
import com.kexue.skills.service.AccountService;
import com.kexue.skills.service.ModelPriceService;
import com.kexue.skills.service.PackageConfigService;
import com.kexue.skills.entity.ModelPrice;
import com.kexue.skills.entity.PackageConfig;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -43,6 +45,9 @@ public class AccountServiceImpl implements AccountService {
@Resource
private ModelPriceService modelPriceService;
@Resource
private PackageConfigService packageConfigService;
/**
* 分页查询
*
@ -162,34 +167,63 @@ public class AccountServiceImpl implements AccountService {
this.insert(account);
}
// 2. 保存交易记录
// 2. 计算积分
BigDecimal points = BigDecimal.ZERO;
String rechargeRemark = "";
// 检查是否有套餐ID
if (businessId != null) {
// 查询套餐配置
PackageConfig packageConfig = packageConfigService.queryById(businessId);
if (packageConfig != null) {
// 套餐的baseAmount和giftAmount已经是积分直接使用
points = packageConfig.getBaseAmount().add(packageConfig.getGiftAmount());
// 根据套餐信息生成remark
rechargeRemark = "购买套餐:" + packageConfig.getName() + ",获得" + points + "积分";
} else {
// 没有找到套餐按照原来的逻辑计算积分
points = amount.multiply(BigDecimal.valueOf(100));
rechargeRemark = "充值" + amount + "元,获得" + points + "积分";
}
} else {
// 没有套餐ID按照原来的逻辑计算积分
points = amount.multiply(BigDecimal.valueOf(100));
rechargeRemark = "充值" + amount + "元,获得" + points + "积分";
}
// 3. 保存交易记录
AccountTransaction transaction = new AccountTransaction();
transaction.setUserId(userId);
transaction.setUserName(account.getUserName());
transaction.setTransactionType(1); // 充值
transaction.setAmount(amount);
transaction.setAmount(points); // 存储充值金额积分
transaction.setBeforeBalance(account.getBalance());
transaction.setAfterBalance(account.getBalance().add(amount));
transaction.setAfterBalance(account.getBalance().add(points)); // 余额为积分
transaction.setStatus(1); // 成功
transaction.setTransactionNo(transactionNo);
transaction.setPayType(3); // 余额支付
transaction.setBusinessId(businessId);
transaction.setBusinessId(businessId);// 套餐ID
transaction.setBusinessType(businessType);
transaction.setRemark(remark);
// 添加额外备注
if (remark != null && !remark.isEmpty()) {
rechargeRemark += " - " + remark;
}
transaction.setRemark(rechargeRemark);
transaction.setIsExpense(0); // 收入
transaction.setIncomeType("recharge"); // 充值
this.accountTransactionMapper.insert(transaction);
// 3. 更新账户余额
// 4. 更新账户余额使用积分
if (isWithdrawable) {
BigDecimal withdrawableBalance = account.getWithdrawableBalance() == null ? BigDecimal.ZERO : account.getWithdrawableBalance();
account.setWithdrawableBalance(withdrawableBalance.add(amount));
account.setWithdrawableBalance(withdrawableBalance.add(points));
} else {
BigDecimal nonWithdrawableBalance = account.getNonWithdrawableBalance() == null ? BigDecimal.ZERO : account.getNonWithdrawableBalance();
account.setNonWithdrawableBalance(nonWithdrawableBalance.add(amount));
account.setNonWithdrawableBalance(nonWithdrawableBalance.add(points));
}
BigDecimal balance = account.getBalance() == null ? BigDecimal.ZERO : account.getBalance();
account.setBalance(balance.add(amount));
account.setBalance(balance.add(points));
account.setUpdateTime(new Date());
this.update(account);
return 1;
@ -327,15 +361,13 @@ public class AccountServiceImpl implements AccountService {
Assert.notNull(dto.getQuestion(), "问题不能为空");
Assert.notNull(dto.getModelName(), "模型名称不能为空");
Assert.notNull(dto.getQuestion(), "问题不能为空");
Assert.notNull(dto.getModelName(), "模型名称不能为空");
Assert.notNull(dto.getQuestion(), "问题不能为空");
}
/**
* 增加账户余额签到奖励token转换
* 增加账户积分签到奖励token转换
*
* @param userId 用户ID
* @param amount 增加金额
* @param amount 增加积分
* @param transactionNo 交易单号
* @param businessId 业务ID
* @param businessType 业务类型
@ -362,15 +394,20 @@ public class AccountServiceImpl implements AccountService {
transaction.setUserId(userId);
transaction.setUserName(account.getUserName());
transaction.setTransactionType(5); // 签到奖励
transaction.setAmount(amount);
transaction.setAmount(amount); // 存储签到奖励积分
transaction.setBeforeBalance(account.getBalance());
transaction.setAfterBalance(account.getBalance().add(amount));
transaction.setAfterBalance(account.getBalance().add(amount)); // 余额为积分
transaction.setStatus(1); // 成功
transaction.setTransactionNo(transactionNo);
transaction.setPayType(3); // 余额支付
transaction.setBusinessId(businessId);
transaction.setBusinessType(businessType);
transaction.setRemark(remark);
// 在备注中添加积分信息
String signInRemark = "签到奖励" + amount + "积分";
if (remark != null && !remark.isEmpty()) {
signInRemark += " - " + remark;
}
transaction.setRemark(signInRemark);
transaction.setIsExpense(0); // 收入
transaction.setIncomeType("sign_in"); // 签到奖励
this.accountTransactionMapper.insert(transaction);
@ -386,10 +423,10 @@ public class AccountServiceImpl implements AccountService {
}
/**
* 给用户赠送金额不可提现
* 给用户赠送积分不可提现
*
* @param userId 用户ID
* @param amount 赠送金额
* @param amount 赠送积分
* @param transactionNo 交易单号
* @param businessId 业务ID
* @param businessType 业务类型
@ -416,20 +453,25 @@ public class AccountServiceImpl implements AccountService {
transaction.setUserId(userId);
transaction.setUserName(account.getUserName());
transaction.setTransactionType(6); // 赠送
transaction.setAmount(amount);
transaction.setAmount(amount); // 存储赠送积分
transaction.setBeforeBalance(account.getBalance());
transaction.setAfterBalance(account.getBalance().add(amount));
transaction.setAfterBalance(account.getBalance().add(amount)); // 余额为积分
transaction.setStatus(1); // 成功
transaction.setTransactionNo(transactionNo);
transaction.setPayType(3); // 余额支付
transaction.setBusinessId(businessId);
transaction.setBusinessType(businessType);
transaction.setRemark(remark);
// 在备注中添加积分信息
String giftRemark = "赠送" + amount + "积分";
if (remark != null && !remark.isEmpty()) {
giftRemark += " - " + remark;
}
transaction.setRemark(giftRemark);
transaction.setIsExpense(0); // 收入
transaction.setIncomeType("gift"); // 赠送
this.accountTransactionMapper.insert(transaction);
// 3. 更新账户余额赠送金额不可提现
// 3. 更新账户余额赠送积分不可提现
if (account.getNonWithdrawableBalance() == null){
account.setNonWithdrawableBalance(BigDecimal.ZERO);
}

View File

@ -0,0 +1,66 @@
package com.kexue.skills.service.impl;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.kexue.skills.entity.PackageConfig;
import com.kexue.skills.entity.dto.PackageConfigDto;
import com.kexue.skills.mapper.PackageConfigMapper;
import com.kexue.skills.service.PackageConfigService;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.util.List;
/**
* (PackageConfig)表服务实现类
*
* @author 系统生成
* @since 2026-04-11
*/
@Service
public class PackageConfigServiceImpl implements PackageConfigService {
@Resource
private PackageConfigMapper packageConfigMapper;
@Override
public PageInfo<PackageConfig> getPageList(PackageConfigDto queryDto) {
PageHelper.startPage(queryDto.getPageNum(), queryDto.getPageSize());
List<PackageConfig> list = packageConfigMapper.getList(queryDto);
return new PageInfo<>(list);
}
@Override
public List<PackageConfig> getList(PackageConfigDto queryDto) {
return packageConfigMapper.getList(queryDto);
}
@Override
public PackageConfig queryById(Long id) {
return packageConfigMapper.queryById(id);
}
@Override
public PackageConfig insert(PackageConfig packageConfig) {
packageConfigMapper.insert(packageConfig);
return packageConfig;
}
@Override
public PackageConfig update(PackageConfig packageConfig) {
packageConfigMapper.update(packageConfig);
return packageConfig;
}
@Override
public int logicDeleteById(Long id, String updateBy) {
// 由于package_config表没有delete_flag字段这里直接调用物理删除
return packageConfigMapper.deleteById(id);
}
@Override
public int deleteById(Long id) {
return packageConfigMapper.deleteById(id);
}
}

View File

@ -11,6 +11,8 @@ import com.kexue.skills.entity.PaymentOrder;
import com.kexue.skills.service.PayService;
import com.kexue.skills.service.PaymentOrderService;
import com.kexue.skills.service.AccountService;
import com.kexue.skills.service.PackageConfigService;
import com.kexue.skills.entity.PackageConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@ -63,6 +65,9 @@ public class PayServiceImpl implements PayService {
@Resource
private AccountService accountService;
@Resource
private PackageConfigService packageConfigService;
/**
* 生成随机字符串
* @return 随机字符串
@ -505,11 +510,11 @@ public class PayServiceImpl implements PayService {
order.getAmount(),
true, // 可提现
transactionId,
order.getOrderId(),
order.getBusinessId(),
"recharge",
"微信支付充值"
);
logger.info("微信支付回调更新账户余额成功userId={}, amount={}", order.getUserId(), order.getAmount());
logger.info("微信支付回调更新账户余额成功userId={}, amount={}, actualAmount={}", order.getUserId(), order.getAmount(), order.getAmount());
} catch (Exception e) {
logger.error("微信支付回调:更新账户余额失败", e);
// 继续处理不影响回调响应
@ -564,6 +569,7 @@ public class PayServiceImpl implements PayService {
bizContent.put("out_trade_no", order.getOrderNo());
bizContent.put("total_amount", order.getAmount().toString());
bizContent.put("subject", order.getProductName());
bizContent.put("businessId", order.getBusinessId());
bizContent.put("body", order.getProductDesc());
bizContent.put("timeout_express", "30m");
bizContent.put("product_code", "FAST_INSTANT_TRADE_PAY");
@ -647,11 +653,11 @@ public class PayServiceImpl implements PayService {
order.getAmount(),
true, // 可提现
transactionId,
order.getOrderId(),
order.getBusinessId(),
"recharge",
"支付宝支付充值"
);
logger.info("支付宝支付回调更新账户余额成功userId={}, amount={}", order.getUserId(), order.getAmount());
logger.info("支付宝支付回调更新账户余额成功userId={}, amount={}, actualAmount={}", order.getUserId(), order.getAmount(), order.getAmount());
} catch (Exception e) {
logger.error("支付宝支付回调:更新账户余额失败", e);
// 继续处理不影响回调响应

View File

@ -11,8 +11,6 @@ import com.kexue.skills.config.GlmConfig;
import com.kexue.skills.entity.CmsContent;
import com.kexue.skills.entity.CmsTag;
import com.kexue.skills.entity.dto.CmsTagDto;
import com.kexue.skills.entity.dto.SkillPackageInfoDto;
import com.kexue.skills.entity.dto.SkillStructureNodeDto;
import com.kexue.skills.entity.request.SkillAnalyzeRequest;
import com.kexue.skills.entity.request.SkillGenRequest;
import com.kexue.skills.entity.request.SkillPreGenRequest;
@ -24,43 +22,20 @@ import com.kexue.skills.service.CmsTagService;
import com.kexue.skills.service.SkillGenService;
import com.kexue.skills.utils.EscapeCharacterUtils;
import com.kexue.skills.utils.YamlToMapUtil;
import com.kexue.skills.utils.YamlUtil;
import jodd.util.StringUtil;
import lombok.extern.slf4j.Slf4j;
import net.sf.sevenzipjbinding.IInArchive;
import net.sf.sevenzipjbinding.SevenZip;
import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.yaml.snakeyaml.error.YAMLException;
import javax.annotation.Resource;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* 技能生成服务实现
@ -105,7 +80,7 @@ public class SkillGenServiceImpl implements SkillGenService {
StringBuilder tagsList = new StringBuilder();
for (int i = 0; i < tags.size(); i++) {
CmsTag tag = tags.get(i);
tagsList.append(tag.getTagId() + "." + tag.getTagName());
tagsList.append(tag.getTagId()+"."+tag.getTagName());
if (i < tags.size() - 1) {
tagsList.append("");
}
@ -143,7 +118,7 @@ public class SkillGenServiceImpl implements SkillGenService {
}
} catch (Exception e) {
log.error("调用Deepseek API失败: {}", e.getMessage(), e);
throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse);
throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse);
}
return null;
@ -168,7 +143,7 @@ public class SkillGenServiceImpl implements SkillGenService {
StringBuilder tagsList = new StringBuilder();
for (int i = 0; i < tags.size(); i++) {
CmsTag tag = tags.get(i);
tagsList.append(tag.getTagId() + "." + tag.getTagName());
tagsList.append(tag.getTagId()+"."+tag.getTagName());
if (i < tags.size() - 1) {
tagsList.append("");
}
@ -218,7 +193,7 @@ public class SkillGenServiceImpl implements SkillGenService {
}
} catch (Exception e) {
log.error("调用API失败: {}", e.getMessage(), e);
throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + response);
throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ response);
}
return null;
}
@ -246,8 +221,8 @@ public class SkillGenServiceImpl implements SkillGenService {
String defaultIcon = "";
for (int i = 0; i < tags.size(); i++) {
CmsTag tag = tags.get(i);
if (tags1.contains(tag.getTagId() + "")) {
if (StringUtil.isEmpty(defaultIcon)) {
if (tags1.contains(tag.getTagId()+"")) {
if (StringUtil.isEmpty(defaultIcon)){
defaultIcon = tag.getIcon();
}
tagsList.append(tag.getTagName());
@ -349,7 +324,7 @@ public class SkillGenServiceImpl implements SkillGenService {
tagsList.toString(),
request.getRequirement()
);
SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), systemContent, userContent, deepSeekConfig.getChat().getTemperature(), 8192, "text");
SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(),systemContent,userContent,deepSeekConfig.getChat().getTemperature(), 8192,"text");
String deepseekResponse = "";
try {
// 发送HTTP请求到deepseek API
@ -357,10 +332,10 @@ public class SkillGenServiceImpl implements SkillGenService {
log.info("Deepseek API响应: {}", deepseekResponse);
JSONObject responseJson = JSON.parseObject(deepseekResponse);
String content = responseJson.getJSONArray("choices").toJavaList(JSONObject.class).get(0).getJSONObject("message").getString("content");
content = EscapeCharacterUtils.removeEscapeCharacters(content);//去除转义字符
CmsContent cmsContent = getCmsContent(request, content, StpUtil.getLoginIdAsLong(), defaultIcon);
content = EscapeCharacterUtils.removeEscapeCharacters( content);//去除转义字符
CmsContent cmsContent = getCmsContent(request, content, StpUtil.getLoginIdAsLong(),defaultIcon);
List<CmsTag> list = tags.stream().filter(tag -> tag.getTagId() == Long.parseLong(cmsContent.getTags().split(",")[0])).toList();
if (CollectionUtil.isNotEmpty(list)) {
if (CollectionUtil.isNotEmpty( list)){
cmsContent.setIcon(list.get(0).getIcon());
}
// 保存到数据库
@ -368,11 +343,11 @@ public class SkillGenServiceImpl implements SkillGenService {
return cmsContent;
} catch (Exception e) {
log.error("调用Deepseek API失败: {}", e.getMessage(), e);
throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse);
throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse);
}
}
private CmsContent getCmsContent(SkillGenRequest request, String CmsContent, Long userId, String defaultIcon) {
private CmsContent getCmsContent(SkillGenRequest request, String CmsContent,Long userId,String defaultIcon){
CmsContent cmsContent = new CmsContent();
cmsContent.setTitle(request.getName());
cmsContent.setDescription(request.getDescription());
@ -394,8 +369,8 @@ public class SkillGenServiceImpl implements SkillGenService {
cmsContent.setCreateTime(new Date());
cmsContent.setUpdateTime(new Date());
cmsContent.setContentType(1);
cmsContent.setCreateBy(userId + "");
cmsContent.setUpdateBy(userId + "");
cmsContent.setCreateBy(userId+"");
cmsContent.setUpdateBy(userId+"");
cmsContent.setDeleteFlag(0);
cmsContent.setIcon(defaultIcon);
cmsContent.setRequirement(request.getRequirement());
@ -438,11 +413,11 @@ public class SkillGenServiceImpl implements SkillGenService {
if (choices != null && !choices.isEmpty()) {
JSONObject latestChoice = choices.get(0);
JSONObject message = latestChoice.getJSONObject("message");
return EscapeCharacterUtils.removeEscapeCharacters(message.getString("content"));//去除转义字符
return EscapeCharacterUtils.removeEscapeCharacters( message.getString("content"));//去除转义字符
}
} catch (Exception e) {
log.error("调用Deepseek API失败: {}", e.getMessage(), e);
throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse);
throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse);
}
return null;
@ -468,11 +443,11 @@ public class SkillGenServiceImpl implements SkillGenService {
if (choices != null && !choices.isEmpty()) {
JSONObject latestChoice = choices.get(0);
JSONObject message = latestChoice.getJSONObject("message");
return EscapeCharacterUtils.removeEscapeCharacters(message.getString("content"));//去除转义字符
return EscapeCharacterUtils.removeEscapeCharacters( message.getString("content"));//去除转义字符
}
} catch (Exception e) {
log.error("调用Deepseek API失败: {}", e.getMessage(), e);
throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse);
throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse);
}
return null;
@ -493,7 +468,7 @@ public class SkillGenServiceImpl implements SkillGenService {
StringBuilder tagsList = new StringBuilder();
for (int i = 0; i < tags.size(); i++) {
CmsTag tag = tags.get(i);
tagsList.append(tag.getTagId() + "." + tag.getTagName());
tagsList.append(tag.getTagId()+"."+tag.getTagName());
if (i < tags.size() - 1) {
tagsList.append("");
}
@ -556,7 +531,7 @@ public class SkillGenServiceImpl implements SkillGenService {
// 生成CmsContent对象
CmsContent cmsContent = getCmsContent(request, skillJson.getString("content"), StpUtil.getLoginIdAsLong(), "");
List<CmsTag> list = tags.stream().filter(tag -> tag.getTagId() == Long.parseLong(cmsContent.getTags().split(",")[0])).toList();
if (CollectionUtil.isNotEmpty(list)) {
if (CollectionUtil.isNotEmpty( list)){
cmsContent.setIcon(list.get(0).getIcon());
}
// 保存到数据库
@ -565,7 +540,7 @@ public class SkillGenServiceImpl implements SkillGenService {
}
} catch (Exception e) {
log.error("调用Deepseek API失败: {}", e.getMessage(), e);
throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse);
throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse);
}
return null;
@ -645,7 +620,7 @@ public class SkillGenServiceImpl implements SkillGenService {
}
long finalL = l;
List<CmsTag> list = tags.stream().filter(tag -> tag.getTagId() == finalL).toList();
if (CollectionUtil.isNotEmpty(list)) {
if (CollectionUtil.isNotEmpty(list)){
cmsContent.setIcon(list.get(0).getIcon());
}
}
@ -667,7 +642,7 @@ public class SkillGenServiceImpl implements SkillGenService {
try {
Map<String, Object> yamlMap = YamlToMapUtil.yamlTextToMap(yamlContent);
Map<String, Object> packageMap = (Map<String, Object>) yamlMap.get("package");
Map<String, Object> packageMap = (Map<String, Object>)yamlMap.get("package");
String skillName = (String) packageMap.get("name");
String skillDescription = (String) packageMap.get("description");
List<?> tagList = (List<?>) packageMap.get("tags");
@ -680,11 +655,11 @@ public class SkillGenServiceImpl implements SkillGenService {
request.setIntroduce(genIntroduceByDescription(skillDescription));
return getCmsContent(request, yamlContent, StpUtil.getLoginIdAsLong(), skillIcon);
} catch (YAMLException e) {
throw new BizException("yaml解析失败:" + e.getMessage());
throw new BizException("yaml解析失败:"+ e.getMessage());
}
}
private String getDefaultIcon(String tagId) {
private String getDefaultIcon(String tagId){
CmsTagDto tagDto = new CmsTagDto();
tagDto.setDeleteFlag(0);
tagDto.setStatus(1);
@ -693,8 +668,8 @@ public class SkillGenServiceImpl implements SkillGenService {
String defaultIcon = "";
for (int i = 0; i < tagList.size(); i++) {
CmsTag tag = tagList.get(i);
if (tagId.contains(tag.getTagId() + "")) {
if (StringUtil.isEmpty(defaultIcon)) {
if (tagId.contains(tag.getTagId()+"")) {
if (StringUtil.isEmpty(defaultIcon)){
defaultIcon = tag.getIcon();
break;
}
@ -703,14 +678,14 @@ public class SkillGenServiceImpl implements SkillGenService {
return defaultIcon;
}
public SkillGenRequest parseSkillMdText(String skillMdText, List<CmsTag> tags) {
public SkillGenRequest parseSkillMdText(String skillMdText,List<CmsTag> tags) {
String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions";
// 将标签名称拼接成逗号分隔的字符串
StringBuilder tagsList = new StringBuilder();
for (int i = 0; i < tags.size(); i++) {
CmsTag tag = tags.get(i);
tagsList.append(tag.getTagId() + "." + tag.getTagName());
tagsList.append(tag.getTagId()+"."+tag.getTagName());
if (i < tags.size() - 1) {
tagsList.append("");
}
@ -767,7 +742,7 @@ public class SkillGenServiceImpl implements SkillGenService {
}
} catch (Exception e) {
log.error("调用Deepseek API失败: {}", e.getMessage(), e);
throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse);
throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse);
}
return null;
@ -776,627 +751,6 @@ public class SkillGenServiceImpl implements SkillGenService {
@Override
public CmsContent uploadSkillV4(byte[] fileBytes, String fileName) {
log.info("上传技能压缩包V4请求: {}", fileName);
try {
// 1. 从文件名提取技能名称
String skillName = extractSkillName(fileName);
// 2. 解析压缩包构建目录树结构
List<SkillStructureNodeDto> structureNodes = parseArchiveToStructure(fileBytes, fileName);
// 3. 从SKILL.md或README.md中提取描述信息如果存在
String description = extractDescriptionFromStructure(structureNodes);
if (description == null || description.isEmpty()) {
description = "AI生成的技能包";
}
// 4. 提取标签如果没有则使用默认标签
List<String> tags = extractTagsFromStructure(structureNodes);
if (tags == null || tags.isEmpty()) {
tags = Arrays.asList("通用");
}
// 5. 构建packageInfo
SkillPackageInfoDto packageInfo = new SkillPackageInfoDto();
packageInfo.setName(skillName);
packageInfo.setVersion("1.0.0");
packageInfo.setDescription(description);
packageInfo.setAuthor("AI技能生成助手");
packageInfo.setCreated(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
packageInfo.setTags(tags);
packageInfo.setStructure(structureNodes);
// 6. 生成YAML内容
String yamlContent = generateYamlContent(packageInfo);
log.info("生成的YAML内容:\n{}", yamlContent);
// 7. 构建SkillGenRequest
SkillGenRequest request = new SkillGenRequest();
request.setName(skillName);
request.setDescription(description);
// request.setIntroduce(genIntroduceByDescription(description));
request.setTags(tags);
// 8. 生成CmsContent
CmsContent cmsContent = getCmsContent(request, yamlContent, StpUtil.getLoginIdAsLong(), "");
// 9. 设置图标
setSkillIcon(cmsContent, tags);
cmsContentMapper.insert(cmsContent);
return cmsContent;
} catch (Exception e) {
log.error("上传技能压缩包V4失败: {}", e.getMessage(), e);
throw new BizException("上传技能压缩包V4失败:" + e.getMessage());
}
}
/**
* 解析压缩包为目录树结构
*/
private List<SkillStructureNodeDto> parseArchiveToStructure(byte[] fileBytes, String fileName) {
Map<String, byte[]> fileMap = new HashMap<>();
// 解压文件
if (fileName.toLowerCase().endsWith(".zip")) {
extractZipFiles(fileBytes, fileMap);
} else if (fileName.toLowerCase().endsWith(".rar")) {
extractRarFiles(fileBytes, fileMap);
} else {
throw new BizException("不支持的压缩包格式,仅支持 zip 或 rar");
}
// 构建目录树
return buildDirectoryTree(fileMap);
}
/**
* 构建目录树结构
*/
private List<SkillStructureNodeDto> buildDirectoryTree(Map<String, byte[]> fileMap) {
log.info("开始构建目录树,文件数量: {}", fileMap.size());
// 收集所有唯一的目录路径
Set<String> directorySet = new TreeSet<>();
for (String filePath : fileMap.keySet()) {
String normalizedPath = filePath.replace("\\", "/");
// 从文件路径提取所有父目录
int lastSlashIndex = normalizedPath.lastIndexOf("/");
while (lastSlashIndex > 0) {
String parentDir = normalizedPath.substring(0, lastSlashIndex);
directorySet.add(parentDir);
lastSlashIndex = parentDir.lastIndexOf("/");
}
}
log.info("检测到目录: {}", directorySet);
// 创建节点映射path -> node
Map<String, SkillStructureNodeDto> pathToNodeMap = new HashMap<>();
// 先创建所有目录节点
for (String dirPath : directorySet) {
String[] parts = dirPath.split("/");
String dirName = parts[parts.length - 1];
String parentPath = dirPath.contains("/") ? dirPath.substring(0, dirPath.lastIndexOf("/")) : "";
SkillStructureNodeDto dirNode = new SkillStructureNodeDto();
dirNode.setName(dirName);
dirNode.setType("directory");
dirNode.setPath(parentPath.isEmpty() ? "/" : parentPath);
dirNode.setFormat("dir");
dirNode.setDescription(dirName + " 目录");
dirNode.setChildren(new ArrayList<>());
pathToNodeMap.put(dirPath, dirNode);
log.debug("创建目录节点: name={}, path={}", dirName, dirNode.getPath());
}
// 将目录节点添加到父节点
List<SkillStructureNodeDto> rootChildren = new ArrayList<>();
for (Map.Entry<String, SkillStructureNodeDto> entry : pathToNodeMap.entrySet()) {
String dirPath = entry.getKey();
SkillStructureNodeDto node = entry.getValue();
String parentPath = dirPath.contains("/") ? dirPath.substring(0, dirPath.lastIndexOf("/")) : "";
if (parentPath.isEmpty()) {
// 一级目录直接添加到根节点
rootChildren.add(node);
} else {
// 找到父目录并添加
SkillStructureNodeDto parentNode = pathToNodeMap.get(parentPath);
if (parentNode != null && parentNode.getChildren() != null) {
parentNode.getChildren().add(node);
} else {
log.warn("找不到父目录: {}, 将{}添加到根节点", parentPath, dirPath);
rootChildren.add(node);
}
}
}
// 再处理所有文件
for (Map.Entry<String, byte[]> entry : fileMap.entrySet()) {
String filePath = entry.getKey().replace("\\", "/");
if (filePath.endsWith("/")) {
continue; // 跳过目录条目
}
int lastSlashIndex = filePath.lastIndexOf("/");
String fileName = lastSlashIndex >= 0 ? filePath.substring(lastSlashIndex + 1) : filePath;
String parentPath = lastSlashIndex >= 0 ? filePath.substring(0, lastSlashIndex) : "";
SkillStructureNodeDto fileNode = new SkillStructureNodeDto();
fileNode.setName(fileName);
fileNode.setType("file");
fileNode.setPath(parentPath.isEmpty() ? "/" : parentPath);
fileNode.setFormat(getFileFormat(fileName));
fileNode.setDescription(fileName + " 文件");
// 读取文件内容仅文本文件图片等二进制文件不读取内容
String fileFormat = getFileFormat(fileName);
boolean isBinaryFile = isBinaryFormat(fileFormat);
if (!isBinaryFile) {
// 文本文件读取内容
try {
String content = new String(entry.getValue(), StandardCharsets.UTF_8);
fileNode.setContent(content);
} catch (Exception e) {
log.warn("读取文件内容失败: {}", filePath, e);
fileNode.setContent("");
}
} else {
// 二进制文件如图片不读取内容仅记录文件大小信息
int fileSize = entry.getValue().length;
fileNode.setContent("");
log.debug("二进制文件: {} ({} bytes)", fileName, fileSize);
}
// 添加到父节点
if (parentPath.isEmpty()) {
// 根目录下的文件
rootChildren.add(fileNode);
} else {
SkillStructureNodeDto parentNode = pathToNodeMap.get(parentPath);
if (parentNode != null && parentNode.getChildren() != null) {
parentNode.getChildren().add(fileNode);
} else {
log.warn("找不到文件的父目录: {}, 将{}添加到根节点", parentPath, filePath);
rootChildren.add(fileNode);
}
}
log.debug("创建文件节点: name={}, path={}", fileName, fileNode.getPath());
}
// 删除不再强制生成skills.md保持压缩包原有结构
// 之前的代码ensureSkillsMdExists(rootChildren);
// 如果只有一个根目录节点将其children提升到根层级去掉最外层技能包目录
if (rootChildren.size() == 1 && "directory".equals(rootChildren.get(0).getType())) {
SkillStructureNodeDto rootDir = rootChildren.get(0);
String removedDirName = rootDir.getName();
log.info("检测到单个根目录节点: {}将其children提升到根层级", removedDirName);
// 获取其children作为新的根节点
List<SkillStructureNodeDto> newRootChildren = rootDir.getChildren();
if (newRootChildren != null && !newRootChildren.isEmpty()) {
rootChildren = newRootChildren;
// 递归更新所有节点的path去除被移除的目录名前缀
updatePathsAfterFlattening(rootChildren, removedDirName);
}
}
log.info("目录树构建完成,根节点数量: {}", rootChildren.size());
for (SkillStructureNodeDto node : rootChildren) {
log.info(" 根节点: {} ({})", node.getName(), node.getType());
}
return rootChildren;
}
/**
* 扁平化后递归更新所有节点的path
*/
private void updatePathsAfterFlattening(List<SkillStructureNodeDto> nodes, String removedDirPrefix) {
if (nodes == null || nodes.isEmpty()) {
return;
}
for (SkillStructureNodeDto node : nodes) {
String oldPath = node.getPath();
String newPath;
// 如果path是被移除的目录名改为 "/"
if (removedDirPrefix.equals(oldPath)) {
newPath = "/";
} else if (oldPath.startsWith(removedDirPrefix + "/")) {
// 如果被移除目录名是前缀去掉它
newPath = "/" + oldPath.substring(removedDirPrefix.length() + 1);
} else {
// 其他情况保持不变
newPath = oldPath;
}
node.setPath(newPath);
// 递归处理子节点
if ("directory".equals(node.getType()) && node.getChildren() != null) {
updatePathsAfterFlattening(node.getChildren(), removedDirPrefix);
}
}
}
/**
* 确保根目录下有skills.md文件
*/
private void ensureSkillsMdExists(List<SkillStructureNodeDto> rootChildren) {
boolean hasSkillsMd = false;
for (SkillStructureNodeDto node : rootChildren) {
if ("skills.md".equals(node.getName())) {
hasSkillsMd = true;
break;
}
}
if (!hasSkillsMd) {
SkillStructureNodeDto skillsMd = new SkillStructureNodeDto();
skillsMd.setName("skills.md");
skillsMd.setType("file");
skillsMd.setPath("/");
skillsMd.setFormat("markdown");
skillsMd.setDescription("技能说明文档");
skillsMd.setContent("# 技能说明\n\n请在此处填写技能的详细说明。\n");
rootChildren.add(skillsMd);
}
}
/**
* 从目录结构中提取描述
*/
private String extractDescriptionFromStructure(List<SkillStructureNodeDto> structureNodes) {
// 查找SKILL.md或README.md文件
for (SkillStructureNodeDto node : structureNodes) {
if ("file".equals(node.getType())) {
String lowerName = node.getName().toLowerCase();
if ((lowerName.equals("skill.md") || lowerName.equals("readme.md")) && node.getContent() != null) {
// 提取前200个字符作为描述
String content = node.getContent();
if (content.length() > 200) {
return content.substring(0, 200);
}
return content;
}
} else if ("directory".equals(node.getType()) && node.getChildren() != null) {
// 递归查找子目录
String desc = extractDescriptionFromStructure(node.getChildren());
if (desc != null && !desc.isEmpty()) {
return desc;
}
}
}
return null;
}
/**
* 从目录结构中提取标签
*/
private List<String> extractTagsFromStructure(List<SkillStructureNodeDto> structureNodes) {
// 这里可以解析SKILL.md中的front matter提取标签
// 暂时返回空列表由调用方设置默认值
return new ArrayList<>();
}
/**
* 生成YAML内容
*/
private String generateYamlContent(SkillPackageInfoDto packageInfo) {
StringBuilder yaml = new StringBuilder();
yaml.append("package:\n");
yaml.append(" name: ").append(quoteIfNecessary(packageInfo.getName())).append("\n");
yaml.append(" version: ").append(quoteIfNecessary(packageInfo.getVersion())).append("\n");
// description可能包含特殊字符使用块标量
String description = packageInfo.getDescription();
if (description != null && !description.isEmpty()) {
yaml.append(" description: |\n");
String[] lines = description.split("\n");
for (String line : lines) {
yaml.append(" ").append(line).append("\n");
}
} else {
yaml.append(" description: \"\"\n");
}
yaml.append(" author: ").append(quoteIfNecessary(packageInfo.getAuthor())).append("\n");
yaml.append(" created: ").append(quoteIfNecessary(packageInfo.getCreated())).append("\n");
// tags数组
yaml.append(" tags:\n");
if (packageInfo.getTags() != null) {
for (String tag : packageInfo.getTags()) {
yaml.append(" - ").append(quoteIfNecessary(tag)).append("\n");
}
}
// structure目录树
yaml.append(" structure:\n");
if (packageInfo.getStructure() != null) {
appendStructureNodes(yaml, packageInfo.getStructure(), 2);
}
return yaml.toString();
}
/**
* 判断是否需要引号需要则加双引号
*/
private String quoteIfNecessary(String value) {
if (value == null) {
return "\"\"";
}
// 如果包含特殊字符或可能产生歧义加双引号
if (value.contains(":") || value.contains("#") || value.contains("{") ||
value.contains("}") || value.contains("[") || value.contains("]") ||
value.contains(",") || value.contains("&") || value.contains("*") ||
value.contains("?") || value.contains("|") || value.contains("-") ||
value.contains("<") || value.contains(">") || value.contains("=") ||
value.contains("!") || value.contains("%") || value.contains("@") ||
value.contains("`") || value.startsWith("'") || value.startsWith(" ") ||
value.endsWith(" ")) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
return value;
}
/**
* 递归追加structure节点到YAML
*/
private void appendStructureNodes(StringBuilder yaml, List<SkillStructureNodeDto> nodes, int indentLevel) {
if (nodes == null || nodes.isEmpty()) {
return;
}
String indent = " ".repeat(indentLevel);
for (SkillStructureNodeDto node : nodes) {
yaml.append(indent).append("- name: ").append(node.getName()).append("\n");
yaml.append(indent).append(" type: ").append(node.getType()).append("\n");
yaml.append(indent).append(" path: ").append(node.getPath()).append("\n");
yaml.append(indent).append(" format: ").append(node.getFormat()).append("\n");
if ("file".equals(node.getType())) {
// 文件类型添加content
if (node.getContent() != null && !node.getContent().isEmpty()) {
String rawContent = node.getContent();
// 如果是图片格式Base64字符串需要按行分割每行80字符
if ("image".equals(node.getFormat())) {
int lineLength = 80;
StringBuilder base64Lines = new StringBuilder();
for (int i = 0; i < rawContent.length(); i += lineLength) {
if (i > 0) {
base64Lines.append("\n");
}
int end = Math.min(i + lineLength, rawContent.length());
base64Lines.append(rawContent.substring(i, end));
}
rawContent = base64Lines.toString();
}
// 将Tab替换为4个空格YAML不允许Tab
rawContent = rawContent.replace("\t", " ");
// 分割成行
String[] lines = rawContent.split("\n", -1);
// 去除末尾的空行
int lastNonEmpty = lines.length - 1;
while (lastNonEmpty >= 0 && lines[lastNonEmpty].trim().isEmpty()) {
lastNonEmpty--;
}
if (lastNonEmpty >= 0) {
yaml.append(indent).append(" content: |");
// 如果原内容有尾随换行保留一个
if (lastNonEmpty < lines.length - 1) {
yaml.append("\n");
} else {
yaml.append("-\n"); // |- 表示去掉末尾换行
}
// 输出内容行每行添加统一的缩进
for (int i = 0; i <= lastNonEmpty; i++) {
yaml.append(indent).append(" ").append(lines[i]).append("\n");
}
}
}
} else if ("directory".equals(node.getType())) {
// 目录类型添加children
if (node.getChildren() != null && !node.getChildren().isEmpty()) {
yaml.append(indent).append(" children:\n");
appendStructureNodes(yaml, node.getChildren(), indentLevel + 1);
} else {
yaml.append(indent).append(" children: []\n");
}
}
}
}
/**
* 转义YAML字符串中的特殊字符
*/
private String escapeYamlString(String str) {
if (str == null) {
return "";
}
// 转义双引号反斜杠等特殊字符
return str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
}
/**
* 获取文件格式
*/
private String getFileFormat(String fileName) {
if (fileName == null) {
return "unknown";
}
String lowerName = fileName.toLowerCase();
if (lowerName.endsWith(".md")) {
return "markdown";
} else if (lowerName.endsWith(".py")) {
return "python";
} else if (lowerName.endsWith(".txt")) {
return "text";
} else if (lowerName.endsWith(".json")) {
return "json";
} else if (lowerName.endsWith(".yaml") || lowerName.endsWith(".yml")) {
return "yaml";
} else if (lowerName.endsWith(".js") || lowerName.endsWith(".ts")) {
return "javascript";
} else if (lowerName.endsWith(".html")) {
return "html";
} else if (lowerName.endsWith(".css")) {
return "css";
} else if (lowerName.endsWith(".xml")) {
return "xml";
} else if (lowerName.endsWith(".csv")) {
return "csv";
} else if (lowerName.endsWith(".png") || lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") ||
lowerName.endsWith(".gif") || lowerName.endsWith(".bmp") || lowerName.endsWith(".webp") ||
lowerName.endsWith(".svg") || lowerName.endsWith(".ico")) {
return "image";
} else if (lowerName.endsWith(".pdf")) {
return "pdf";
} else if (lowerName.endsWith(".zip") || lowerName.endsWith(".rar") || lowerName.endsWith(".7z")) {
return "archive";
} else if (lowerName.endsWith(".mp4") || lowerName.endsWith(".avi") || lowerName.endsWith(".mov")) {
return "video";
} else if (lowerName.endsWith(".mp3") || lowerName.endsWith(".wav") || lowerName.endsWith(".ogg")) {
return "audio";
}
return "unknown";
}
/**
* 判断是否为二进制文件格式不需要读取文本内容
* 注意image 类型不在此列因为图片需要转Base64
*/
private boolean isBinaryFormat(String format) {
if (format == null) {
return false;
}
return "pdf".equals(format) || "archive".equals(format) ||
"video".equals(format) || "audio".equals(format) || "unknown".equals(format);
}
/**
* 设置技能图标
*/
private void setSkillIcon(CmsContent cmsContent, List<String> tags) {
if (tags == null || tags.isEmpty()) {
return;
}
try {
String firstTagId = tags.get(0);
CmsTagDto tagDto = new CmsTagDto();
tagDto.setDeleteFlag(0);
tagDto.setStatus(1);
List<CmsTag> tagList = cmsTagService.getList(tagDto);
for (CmsTag tag : tagList) {
if (firstTagId.contains(tag.getTagId() + "")) {
cmsContent.setIcon(tag.getIcon());
break;
}
}
} catch (Exception e) {
log.warn("设置技能图标失败: {}", e.getMessage());
}
}
/**
* 解压 ZIP 文件
*/
private void extractZipFiles(byte[] fileBytes, Map<String, byte[]> fileMap) {
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(fileBytes))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (!entry.isDirectory()) {
String entryName = entry.getName();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = zis.read(buffer)) > 0) {
baos.write(buffer, 0, len);
}
fileMap.put(entryName, baos.toByteArray());
}
zis.closeEntry();
}
} catch (Exception e) {
log.error("解压 ZIP 文件失败: {}", e.getMessage(), e);
throw new BizException("解压 ZIP 文件失败:" + e.getMessage());
}
}
/**
* 解压 RAR 文件
*/
private void extractRarFiles(byte[] fileBytes, Map<String, byte[]> fileMap) {
File tempFile = null;
try {
// 创建临时文件
tempFile = File.createTempFile("skill_rar_", ".rar");
try (FileOutputStream fos = new FileOutputStream(tempFile)) {
fos.write(fileBytes);
}
// 使用 SevenZipJBinding 解压 RAR
RandomAccessFile randomAccessFile = new RandomAccessFile(tempFile, "r");
IInArchive archive = SevenZip.openInArchive(null, new RandomAccessFileInStream(randomAccessFile));
int itemCount = archive.getNumberOfItems();
for (int i = 0; i < itemCount; i++) {
Object isFolder = archive.getProperty(i, net.sf.sevenzipjbinding.PropID.IS_FOLDER);
if (!(isFolder instanceof Boolean) || !((Boolean) isFolder)) {
String path = (String) archive.getProperty(i, net.sf.sevenzipjbinding.PropID.PATH);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
archive.extractSlow(i, data -> {
try {
baos.write(data);
return data.length;
} catch (Exception e) {
throw new RuntimeException(e);
}
});
fileMap.put(path, baos.toByteArray());
}
}
archive.close();
randomAccessFile.close();
} catch (Exception e) {
log.error("解压 RAR 文件失败: {}", e.getMessage(), e);
throw new BizException("解压 RAR 文件失败:" + e.getMessage());
} finally {
if (tempFile != null && tempFile.exists()) {
tempFile.delete();
}
}
}
/**
* 从文件名提取技能名称
*/
private String extractSkillName(String fileName) {
if (fileName.contains(".")) {
int dotIndex = fileName.lastIndexOf(".");
return fileName.substring(0, dotIndex);
}
return fileName;
}
}

View File

@ -94,3 +94,7 @@ web:
upload:
path: /kexue/agentSkills/upload/
# 雪花算法配置
snowflake:
workid: 1 # 机器ID分布式部署时需要保证唯一

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kexue.skills.mapper.PackageConfigMapper">
<resultMap type="com.kexue.skills.entity.PackageConfig" id="PackageConfigMap">
<result property="id" column="id" jdbcType="BIGINT"/>
<result property="name" column="name" jdbcType="VARCHAR"/>
<result property="price" column="price" jdbcType="DECIMAL"/>
<result property="baseAmount" column="base_amount" jdbcType="DECIMAL"/>
<result property="giftAmount" column="gift_amount" jdbcType="DECIMAL"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
<result property="updateTime" column="update_time" jdbcType="TIMESTAMP"/>
</resultMap>
<!--查询单个-->
<select id="queryById" resultMap="PackageConfigMap">
select
id, name, price, base_amount, gift_amount, create_time, update_time
from package_config
where id = #{id}
</select>
<!--分页查询-->
<select id="getPageList" resultMap="PackageConfigMap">
select
id, name, price, base_amount, gift_amount, create_time, update_time
from package_config
<where>
<if test="name != null and name != ''">
and name like concat('%', #{name}, '%')
</if>
</where>
<if test="sortBy != null and sortBy != ''">
order by ${sortBy} ${sortDesc ? 'desc' : 'asc'}
</if>
</select>
<!--查询列表-->
<select id="getList" resultMap="PackageConfigMap">
select
id, name, price, base_amount, gift_amount, create_time, update_time
from package_config
<where>
<if test="name != null and name != ''">
and name like concat('%', #{name}, '%')
</if>
</where>
</select>
<!--新增数据-->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into package_config
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="name != null">name,</if>
<if test="price != null">price,</if>
<if test="baseAmount != null">base_amount,</if>
<if test="giftAmount != null">gift_amount,</if>
<if test="createTime != null">create_time,</if>
<if test="updateTime != null">update_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="name != null">#{name},</if>
<if test="price != null">#{price},</if>
<if test="baseAmount != null">#{baseAmount},</if>
<if test="giftAmount != null">#{giftAmount},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateTime != null">#{updateTime},</if>
</trim>
</insert>
<!--更新数据-->
<update id="update">
update package_config
<set>
<if test="name != null">name = #{name},</if>
<if test="price != null">price = #{price},</if>
<if test="baseAmount != null">base_amount = #{baseAmount},</if>
<if test="giftAmount != null">gift_amount = #{giftAmount},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</set>
where id = #{id}
</update>
<!--删除-->
<delete id="deleteById">
delete from package_config
where id = #{id}
</delete>
</mapper>