Bang Hu
2025-09-03 bd28d26d3da636718aeb73edca00f3da6ecbe4b2
交易确认添加积分扣减API
7个文件已添加
3个文件已修改
561 ■■■■■ 已修改文件
src/main/java/com/webmanage/controller/PointsController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/webmanage/dto/DeductUserPointsDTO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/webmanage/entity/PointsTransaction.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/webmanage/mapper/PointsTransactionMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/webmanage/service/PointsFlowService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/webmanage/service/impl/PointsFlowServiceImpl.java 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
target/classes/application-dev.yml 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
target/classes/application-prod.yml 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
target/classes/application-test.yml 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
target/classes/application.yml 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/webmanage/controller/PointsController.java
@@ -272,6 +272,24 @@
        }
    }
    // ==================== 积分扣减 ====================
    @PostMapping("/user/deduct")
    @ApiOperation("扣减用户积分")
    public Result<Object> deductUserPoints(@Valid @RequestBody DeductUserPointsDTO deductDTO) {
        try {
            boolean result = pointsFlowService.deductUserPoints(deductDTO);
            if (result) {
                return Result.success("积分扣减成功");
            } else {
                return Result.error("积分扣减失败");
            }
        } catch (Exception e) {
            log.error("积分扣减失败", e);
            return Result.error("积分扣减失败:" + e.getMessage());
        }
    }
    // ==================== 积分流水数据类目 ====================
    @GetMapping("/flow/categories")
src/main/java/com/webmanage/dto/DeductUserPointsDTO.java
New file
@@ -0,0 +1,41 @@
package com.webmanage.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;
/**
 * 扣减用户积分DTO
 */
@Data
@ApiModel(value = "DeductUserPointsDTO", description = "扣减用户积分")
public class DeductUserPointsDTO {
    @ApiModelProperty("用户ID")
    @NotBlank(message = "用户ID不能为空")
    private String userId;
    @ApiModelProperty("单位ID")
    private String unitId;
    @ApiModelProperty("扣减积分数量")
    @NotNull(message = "扣减积分数量不能为空")
    @Positive(message = "扣减积分数量必须大于0")
    private Integer points;
    @ApiModelProperty("订单ID")
    private String orderId;
    @ApiModelProperty("扣减原因/备注")
    private String remark;
    @ApiModelProperty("数据类目")
    private String dataCategory = "resource_transaction";
    @ApiModelProperty("数据类型")
    private Integer dataType = 1; // 1表示消耗
}
src/main/java/com/webmanage/entity/PointsTransaction.java
New file
@@ -0,0 +1,74 @@
package com.webmanage.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;
/**
 * 积分交易记录实体,对应表 tb_points_transaction
 */
@Data
@TableName("tb_points_transaction")
@ApiModel(value = "PointsTransaction", description = "积分交易记录")
public class PointsTransaction {
    @ApiModelProperty("主键ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty("数据类目")
    @TableField("data_category")
    private String dataCategory;
    @ApiModelProperty("名称")
    @TableField("transaction_name")
    private String transactionName;
    @ApiModelProperty("时间")
    @TableField("transaction_time")
    private LocalDateTime transactionTime;
    @ApiModelProperty("积分变动值")
    @TableField("points_change")
    private Integer pointsChange;
    @ApiModelProperty("积分规则类型:获取/消耗")
    @TableField("rule_type")
    private String ruleType;
    @ApiModelProperty("用户ID")
    @TableField("user_id")
    private Long userId;
    @ApiModelProperty("企业ID")
    @TableField("unit_id")
    private Long unitId;
    @ApiModelProperty("用户类型:个人用户/单位用户")
    @TableField("user_type")
    private String userType;
    @ApiModelProperty("关联规则ID")
    @TableField("rule_id")
    private Long ruleId;
    @ApiModelProperty("关联规则详情ID")
    @TableField("detail_id")
    private Long detailId;
    @ApiModelProperty("创建时间")
    @TableField("created_at")
    private LocalDateTime createdAt;
    @ApiModelProperty("逻辑删除")
    @TableField("deleted")
    private Integer deleted;
}
src/main/java/com/webmanage/mapper/PointsTransactionMapper.java
New file
@@ -0,0 +1,11 @@
package com.webmanage.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.webmanage.entity.PointsTransaction;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PointsTransactionMapper extends BaseMapper<PointsTransaction> {
}
src/main/java/com/webmanage/service/PointsFlowService.java
@@ -3,6 +3,7 @@
import com.baomidou.mybatisplus.extension.service.IService;
import com.webmanage.common.PageResult;
import com.webmanage.dto.AddPointsFlowDTO;
import com.webmanage.dto.DeductUserPointsDTO;
import com.webmanage.dto.PointsFlowQueryDTO;
import com.webmanage.entity.PointsFlow;
import com.webmanage.entity.UserPoints;
@@ -41,6 +42,11 @@
    boolean addPointsFlowByRule(AddPointsFlowDTO addPointsFlowDTO);
    /**
     * 扣减用户积分
     */
    boolean deductUserPoints(DeductUserPointsDTO deductDTO);
    /**
     * 获取用户积分统计
     */
    UserPoints getUserPointsTotal(String userId);
src/main/java/com/webmanage/service/impl/PointsFlowServiceImpl.java
@@ -7,6 +7,7 @@
import com.webmanage.common.BusinessException;
import com.webmanage.common.PageResult;
import com.webmanage.dto.AddPointsFlowDTO;
import com.webmanage.dto.DeductUserPointsDTO;
import com.webmanage.dto.PointsFlowQueryDTO;
import com.webmanage.emun.RuleTypeEnum;
import com.webmanage.entity.PointsFlow;
@@ -14,6 +15,8 @@
import com.webmanage.entity.UserPoints;
import com.webmanage.mapper.PointsFlowMapper;
import com.webmanage.mapper.UserPointsMapper;
import com.webmanage.mapper.PointsTransactionMapper;
import com.webmanage.entity.PointsTransaction;
import com.webmanage.service.PointsFlowService;
import com.webmanage.service.PointsRuleService;
import lombok.extern.slf4j.Slf4j;
@@ -39,6 +42,9 @@
    @Resource
    private PointsRuleService pointsRuleService;
    @Resource
    private PointsTransactionMapper pointsTransactionMapper;
    @Override
    public PageResult<PointsFlow> getPersonalPointsFlowPage(PointsFlowQueryDTO queryDTO) {
@@ -381,7 +387,7 @@
     * 检查积分余额是否足够
     */
    private void checkBalanceSufficient(String userId, String unitId, Integer requiredPoints) {
        // 检查个人积分余额
        // 仅检查个人积分余额(移除单位余额判定)
        QueryWrapper<UserPoints> userWrapper = new QueryWrapper<>();
        userWrapper.eq("deleted", 0)
                  .eq("user_id", userId);
@@ -391,15 +397,7 @@
            throw new BusinessException("个人积分余额不足,当前余额: " + (userPoints != null ? userPoints.getBalance() : 0) + ",需要扣除: " + requiredPoints);
        }
        // 检查单位积分余额
        QueryWrapper<UserPoints> unitWrapper = new QueryWrapper<>();
        unitWrapper.eq("deleted", 0)
                  .eq("unit_id", unitId);
        UserPoints unitPoints = userPointsMapper.selectOne(unitWrapper);
        if (unitPoints == null || unitPoints.getBalance() < requiredPoints) {
            throw new BusinessException("单位积分余额不足,当前余额: " + (unitPoints != null ? unitPoints.getBalance() : 0) + ",需要扣除: " + requiredPoints);
        }
        // 原单位余额校验已移除
    }
    /**
@@ -566,6 +564,83 @@
    }
    /**
     * 扣减用户积分
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean deductUserPoints(DeductUserPointsDTO deductDTO) {
        try {
            String userId = deductDTO.getUserId();
            String unitId = deductDTO.getUnitId();
            Integer points = deductDTO.getPoints();
            String orderId = deductDTO.getOrderId();
            String remark = deductDTO.getRemark();
            String dataCategory = deductDTO.getDataCategory();
            Integer dataType = deductDTO.getDataType();
            if (!StringUtils.hasText(userId)) {
                throw new BusinessException("用户ID不能为空");
            }
            if (points == null || points <= 0) {
                throw new BusinessException("扣减积分数量必须大于0");
            }
            // 检查用户积分余额是否充足
            checkBalanceSufficient(userId, unitId, points);
            // 创建积分流水记录
            PointsFlow pointsFlow = new PointsFlow();
            pointsFlow.setUserId(userId);
            pointsFlow.setUnitId(unitId);
            pointsFlow.setDataCategory(dataCategory != null ? dataCategory : "积分交易");
            pointsFlow.setDataType(dataType != null ? dataType : 1);
            pointsFlow.setPoints(-points); // 负数表示扣减
            pointsFlow.setName(remark != null ? remark : "积分扣减"); // name字段存储remark内容
            pointsFlow.setFlowTime(LocalDateTime.now());
            pointsFlow.setRlueId(null); // 直接扣减,不关联规则
            boolean saved = save(pointsFlow);
            if (!saved) {
                throw new BusinessException("保存积分流水失败");
            }
            // 更新用户积分账户
            updateUserPointsOnly(userId, -points);
            // 新增积分交易记录(与流水同事务)
            PointsTransaction trans = new PointsTransaction();
            // tb_points_transaction 的 chk_data_category 仅允许 '用户参与'、'其他'
            // 这里将业务类目映射为数据库允许的值
            trans.setDataCategory("用户参与");
            trans.setTransactionName(pointsFlow.getName());
            trans.setTransactionTime(LocalDateTime.now());
            trans.setPointsChange(-points);
            trans.setRuleType("消耗");
            try {
                trans.setUserId(Long.valueOf(userId));
            } catch (Exception ignore) {}
            try {
                if (StringUtils.hasText(unitId)) trans.setUnitId(Long.valueOf(unitId));
            } catch (Exception ignore) {}
            trans.setUserType("个人用户");
            trans.setRuleId(null);
            trans.setDetailId(null);
            trans.setCreatedAt(LocalDateTime.now());
            trans.setDeleted(0);
            int inserted = pointsTransactionMapper.insert(trans);
            log.info("Points transaction inserted rows={}, id={}", inserted, trans.getId());
            if (inserted <= 0 || trans.getId() == null) {
                throw new BusinessException("保存积分交易记录失败");
            }
            return true;
        } catch (Exception e) {
            log.error("扣减用户积分失败", e);
            throw new BusinessException("扣减用户积分失败:" + e.getMessage());
        }
    }
    /**
     * 仅更新提供者(单位)积分账户
     */
    private void updateProviderUnitPoints(String providerUnitId, Integer pointsValue) {
@@ -606,4 +681,40 @@
            userPointsMapper.updateById(unitPoints);
        }
    }
    /**
     * 仅更新个人积分账户(不操作单位账户)
     */
    private void updateUserPointsOnly(String userId, Integer pointsValue) {
        // 更新个人积分账户
        QueryWrapper<UserPoints> userWrapper = new QueryWrapper<>();
        userWrapper.eq("deleted", 0)
                  .eq("user_id", userId);
        UserPoints userPoints = userPointsMapper.selectOne(userWrapper);
        if (userPoints == null) {
            if (pointsValue < 0) {
                throw new BusinessException("个人积分余额不足,无法扣除积分");
            }
            userPoints = new UserPoints();
            userPoints.setUserId(userId);
            userPoints.setUnitId(null);
            userPoints.setBalance(pointsValue);
            userPoints.setTotalEarned(pointsValue > 0 ? pointsValue : 0);
            userPoints.setTotalConsumed(pointsValue < 0 ? Math.abs(pointsValue) : 0);
            userPointsMapper.insert(userPoints);
        } else {
            if (pointsValue < 0 && userPoints.getBalance() + pointsValue < 0) {
                throw new BusinessException("个人积分余额不足,当前余额: " + userPoints.getBalance() + ",需要扣除: " + Math.abs(pointsValue));
            }
            userPoints.setBalance(userPoints.getBalance() + pointsValue);
            if (pointsValue > 0) {
                userPoints.setTotalEarned(userPoints.getTotalEarned() != null ? userPoints.getTotalEarned() + pointsValue : pointsValue);
            }
            if (pointsValue < 0) {
                userPoints.setTotalConsumed(userPoints.getTotalConsumed() != null ? userPoints.getTotalConsumed() + Math.abs(pointsValue) : Math.abs(pointsValue));
            }
            userPoints.setUpdateTime(LocalDateTime.now());
            userPointsMapper.updateById(userPoints);
        }
    }
}
target/classes/application-dev.yml
New file
@@ -0,0 +1,69 @@
spring:
  # 数据源配置
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/web_manage?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
    username: postgres
    password: AES:d9d2d3e0d586e76d02a97c451f3256bffdc806b4c7626904
    druid:
      # 初始连接数
      initial-size: 5
      # 最小连接池数量
      min-idle: 10
      # 最大连接池数量
      max-active: 20
      # 配置获取连接等待超时的时间
      max-wait: 60000
      # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
      time-between-eviction-runs-millis: 60000
      # 配置一个连接在池中最小生存的时间,单位是毫秒
      min-evictable-idle-time-millis: 300000
      # 配置一个连接在池中最大生存的时间,单位是毫秒
      max-evictable-idle-time-millis: 900000
      # 配置检测连接是否有效
      validation-query: SELECT 1
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      # 打开PSCache,并且指定每个连接上PSCache的大小
      pool-prepared-statements: true
      max-pool-prepared-statement-per-connection-size: 20
      # 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
      filters: stat,wall,slf4j
      # 通过connectProperties属性来打开mergeSql功能;慢SQL记录
      connection-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
      # 配置DruidStatFilter
      web-stat-filter:
        enabled: true
        url-pattern: /*
        exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"
      # 配置DruidStatViewServlet
      stat-view-servlet:
        enabled: true
        url-pattern: /druid/*
        reset-enable: false
        login-username: admin
        login-password: 123456
  # Redis配置
  redis:
    host: localhost
    port: 6379
    password:
    database: 0
    timeout: 10000ms
    lettuce:
      pool:
        max-active: 8
        max-wait: -1ms
        max-idle: 8
        min-idle: 0
# MinIO配置
minio:
   endpoint: http://localhost:9000
   access-key: minioadmin
   secret-key: minioadmin
   bucket-name: web-manage
target/classes/application-prod.yml
New file
@@ -0,0 +1,70 @@
  # 数据源配置
spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://192.168.20.52:5432/zypt-v2?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
    username: postgres
    password: AES:f9d2d3e0d586e76d14aba9d24666acef98c4605be9ec680ac4cbeede7ad2
    druid:
      # 初始连接数
      initial-size: 5
      # 最小连接池数量
      min-idle: 10
      # 最大连接池数量
      max-active: 20
      # 配置获取连接等待超时的时间
      max-wait: 60000
      # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
      time-between-eviction-runs-millis: 60000
      # 配置一个连接在池中最小生存的时间,单位是毫秒
      min-evictable-idle-time-millis: 300000
      # 配置一个连接在池中最大生存的时间,单位是毫秒
      max-evictable-idle-time-millis: 900000
      # 配置检测连接是否有效
      validation-query: SELECT 1
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      # 打开PSCache,并且指定每个连接上PSCache的大小
      pool-prepared-statements: true
      max-pool-prepared-statement-per-connection-size: 20
      # 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
      filters: stat,wall,slf4j
      # 通过connectProperties属性来打开mergeSql功能;慢SQL记录
      connection-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
      # 配置DruidStatFilter
      web-stat-filter:
        enabled: true
        url-pattern: /*
        exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"
      # 配置DruidStatViewServlet
      stat-view-servlet:
        enabled: true
        url-pattern: /druid/*
        reset-enable: false
        login-username: admin
        login-password: 123456
  # Redis配置
  redis:
    host: 192.168.20.51
    port: 6379
    password: AES:c8d9cdfddcb4b32c677d657d2c8d56f9e7e4720832656637f5
    database: 4
    timeout: 10000ms
    lettuce:
      pool:
        max-active: 8
        max-wait: -1ms
        max-idle: 8
        min-idle: 0
# MinIO配置
minio:
   endpoint: http://192.168.20.52:9000
   access-key: minioadmin
   secret-key: AES:c4d4cefddd95e6733df755af0e29839c104ee8baa55eb53cdc24
   part-size: 104857600
   bucket-name: dev
target/classes/application-test.yml
New file
@@ -0,0 +1,69 @@
  # 数据源配置
spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/web_manage?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
    username: postgres
    password: zkyxpostgres
    druid:
      # 初始连接数
      initial-size: 5
      # 最小连接池数量
      min-idle: 10
      # 最大连接池数量
      max-active: 20
      # 配置获取连接等待超时的时间
      max-wait: 60000
      # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
      time-between-eviction-runs-millis: 60000
      # 配置一个连接在池中最小生存的时间,单位是毫秒
      min-evictable-idle-time-millis: 300000
      # 配置一个连接在池中最大生存的时间,单位是毫秒
      max-evictable-idle-time-millis: 900000
      # 配置检测连接是否有效
      validation-query: SELECT 1
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      # 打开PSCache,并且指定每个连接上PSCache的大小
      pool-prepared-statements: true
      max-pool-prepared-statement-per-connection-size: 20
      # 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
      filters: stat,wall,slf4j
      # 通过connectProperties属性来打开mergeSql功能;慢SQL记录
      connection-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
      # 配置DruidStatFilter
      web-stat-filter:
        enabled: true
        url-pattern: /*
        exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"
      # 配置DruidStatViewServlet
      stat-view-servlet:
        enabled: true
        url-pattern: /druid/*
        reset-enable: false
        login-username: admin
        login-password: 123456
  # Redis配置
  redis:
    host: 192.168.110.129
    port: 6379
    password:
    database: 0
    timeout: 10000ms
    lettuce:
      pool:
        max-active: 8
        max-wait: -1ms
        max-idle: 8
        min-idle: 0
# MinIO配置
minio:
   endpoint: http://192.168.110.129:9000
   access-key: minioadmin
   secret-key: minioadmin
   bucket-name: web-manage
target/classes/application.yml
New file
@@ -0,0 +1,72 @@
server:
  port: 8080
  servlet:
    context-path: /admin
spring:
  application:
    name: web-manage-back
  profiles:
    active: dev
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8
    serialization:
      write-dates-as-timestamps: false
  #mcv配置
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher
# MyBatis Plus配置
mybatis-plus:
  configuration:
    # 开启驼峰命名
    map-underscore-to-camel-case: true
    # 开启sql日志
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      # 主键类型
      id-type: auto
      # 逻辑删除配置
  mapper-locations: classpath*:/mapper/**/*.xml
# 日志配置
logging:
  level:
    com.webmanage: debug
    org.springframework.web: debug
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n"
# Knife4j配置
knife4j:
  enable: true
  setting:
    language: zh-CN
    enable-swagger-models: true
    enable-document-manage: true
    swagger-model-name: 实体类列表
  basic:
    enable: false
# 购物车配置
cart:
  # Redis缓存过期时间(天)
  expire-days: 30
  # 是否启用数据库持久化
  enable-persistence: true
  # 是否启用数据一致性检查
  enable-consistency-check: true
  # 同步策略:realtime(实时同步)、batch(批量同步)、manual(手动同步)
  sync-strategy: realtime
  async:
    executor:
      core-pool-size: 4
      max-pool-size: 16
      queue-capacity: 500
      keep-alive-seconds: 60
      thread-name-prefix: async-cart-
      wait-for-tasks-to-complete-on-shutdown: true
      await-termination-seconds: 30