feat: 需求部门积分池自动发放功能

- 数据库:新增team_score_pool自动发放字段,新建score_grant_record表
- 后端:新增ScoreAutoGrantService定时任务服务
- 后端:新增ScoreGrantRecord实体和Mapper
- 后端:DepartmentController新增发放记录和手动发放接口
- 后端:TeamScorePoolService新增自动发放配置更新方法
- 前端:部门编辑弹窗新增积分自动发放配置
- 前端:积分池弹窗新增发放记录展示和手动发放按钮
- 前端:department.ts新增相关API
- 前端:config.ts TeamScorePool类型新增自动发放字段
This commit is contained in:
Your Name 2026-01-19 22:44:25 +08:00
parent 3a345c7994
commit d412a7ad42
11 changed files with 1238 additions and 2 deletions

View File

@ -8,8 +8,10 @@ import com.designhub.dto.DepartmentUpdateRequest;
import com.designhub.entity.Brand;
import com.designhub.entity.Department;
import com.designhub.entity.DeptBrandRelation;
import com.designhub.entity.ScoreGrantRecord;
import com.designhub.entity.TeamScorePool;
import com.designhub.service.DepartmentService;
import com.designhub.service.ScoreAutoGrantService;
import com.designhub.service.TeamScorePoolService;
import com.designhub.vo.DepartmentOverviewVO;
import io.swagger.v3.oas.annotations.Operation;
@ -31,6 +33,7 @@ public class DepartmentController {
private final DepartmentService departmentService;
private final TeamScorePoolService teamScorePoolService;
private final ScoreAutoGrantService scoreAutoGrantService;
@Operation(summary = "获取所有部门列表")
@GetMapping
@ -112,6 +115,20 @@ public class DepartmentController {
return Result.success(teamScorePoolService.getByDeptId(deptId));
}
@Operation(summary = "更新部门积分池自动发放配置")
@PutMapping("/{deptId}/auto-grant-config")
public Result<Void> updateAutoGrantConfig(
@PathVariable Long deptId,
@RequestParam(required = false) Integer autoGrantEnabled,
@RequestParam(required = false) String autoGrantPeriod,
@RequestParam(required = false) Integer autoGrantAmount,
@RequestParam(required = false) Integer autoGrantDay,
@RequestParam(required = false) Integer expireUnused) {
teamScorePoolService.updateAutoGrantConfig(deptId, autoGrantEnabled, autoGrantPeriod,
autoGrantAmount, autoGrantDay, expireUnused);
return Result.success();
}
// ==================== 品牌关联管理 ====================
@Operation(summary = "设置部门负责的品牌")
@ -150,6 +167,23 @@ public class DepartmentController {
public Result<List<Department>> getPrimaryDemandDepartments() {
return Result.success(departmentService.getPrimaryDemandDepartments());
}
// ==================== 积分发放记录 ====================
@Operation(summary = "获取部门积分发放记录")
@GetMapping("/{deptId}/grant-records")
public Result<List<ScoreGrantRecord>> getGrantRecords(
@PathVariable Long deptId,
@RequestParam(defaultValue = "50") int limit) {
return Result.success(scoreAutoGrantService.getGrantRecords(deptId, limit));
}
@Operation(summary = "手动触发积分发放(测试/补发)")
@PostMapping("/{deptId}/manual-grant")
public Result<Void> manualGrant(@PathVariable Long deptId) {
scoreAutoGrantService.manualGrant(deptId);
return Result.success();
}
}

View File

@ -0,0 +1,84 @@
package com.designhub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 积分发放记录实体类
*/
@Data
@TableName("score_grant_record")
public class ScoreGrantRecord implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 部门ID
*/
private Long deptId;
/**
* 部门名称
*/
private String deptName;
/**
* 发放类型:AUTO-自动发放,MANUAL-手动充值
*/
private String grantType;
/**
* 发放积分数量
*/
private Integer grantAmount;
/**
* 过期清零积分数量
*/
private Integer expiredAmount;
/**
* 发放前余额
*/
private Integer balanceBefore;
/**
* 发放后余额
*/
private Integer balanceAfter;
/**
* 发放周期:WEEKLY/MONTHLY
*/
private String grantPeriod;
/**
* 周期开始日期
*/
private LocalDate periodStart;
/**
* 周期结束日期
*/
private LocalDate periodEnd;
/**
* 备注
*/
private String remark;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@ -120,6 +120,43 @@ public class TeamScorePool implements Serializable {
*/
private Integer alertThreshold;
// ==================== 自动发放相关字段 ====================
/**
* 是否启用自动发放:0-否,1-是
*/
private Integer autoGrantEnabled;
/**
* 自动发放周期:WEEKLY-每周,MONTHLY-每月
*/
private String autoGrantPeriod;
/**
* 每次自动发放积分数量
*/
private Integer autoGrantAmount;
/**
* 发放日期:周发放为1-7(周一到周日),月发放为1-28
*/
private Integer autoGrantDay;
/**
* 过期未使用是否清零:0-否,1-是
*/
private Integer expireUnused;
/**
* 上次自动发放时间
*/
private LocalDateTime lastGrantTime;
/**
* 下次自动发放时间
*/
private LocalDateTime nextGrantTime;
/**
* 创建时间
*/

View File

@ -0,0 +1,28 @@
package com.designhub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.designhub.entity.ScoreGrantRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 积分发放记录Mapper
*/
@Mapper
public interface ScoreGrantRecordMapper extends BaseMapper<ScoreGrantRecord> {
/**
* 根据部门ID查询发放记录
*/
@Select("SELECT * FROM score_grant_record WHERE dept_id = #{deptId} ORDER BY create_time DESC LIMIT #{limit}")
List<ScoreGrantRecord> selectByDeptId(@Param("deptId") Long deptId, @Param("limit") int limit);
/**
* 根据部门ID查询最近一次自动发放记录
*/
@Select("SELECT * FROM score_grant_record WHERE dept_id = #{deptId} AND grant_type = 'AUTO' ORDER BY create_time DESC LIMIT 1")
ScoreGrantRecord selectLastAutoGrant(@Param("deptId") Long deptId);
}

View File

@ -0,0 +1,292 @@
package com.designhub.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.designhub.entity.Department;
import com.designhub.entity.ScoreGrantRecord;
import com.designhub.entity.TeamScorePool;
import com.designhub.mapper.DepartmentMapper;
import com.designhub.mapper.ScoreGrantRecordMapper;
import com.designhub.mapper.TeamScorePoolMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
/**
* 积分自动发放服务
*/
@Service
@Slf4j
public class ScoreAutoGrantService {
@Autowired
private TeamScorePoolMapper teamScorePoolMapper;
@Autowired
private ScoreGrantRecordMapper scoreGrantRecordMapper;
@Autowired
private DepartmentMapper departmentMapper;
@Autowired
private NotificationService notificationService;
/**
* 定时任务:每天凌晨1点检查并执行自动发放
*/
@Scheduled(cron = "0 0 1 * * ?")
@Transactional
public void autoGrantScores() {
log.info("开始执行积分自动发放定时任务...");
LocalDateTime now = LocalDateTime.now();
int dayOfWeek = now.getDayOfWeek().getValue(); // 1-7 (周一到周日)
int dayOfMonth = now.getDayOfMonth();
// 查询所有启用自动发放的积分池
LambdaQueryWrapper<TeamScorePool> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TeamScorePool::getAutoGrantEnabled, 1);
List<TeamScorePool> pools = teamScorePoolMapper.selectList(wrapper);
log.info("找到 {} 个启用自动发放的积分池", pools.size());
int successCount = 0;
int skipCount = 0;
int errorCount = 0;
for (TeamScorePool pool : pools) {
try {
boolean processed = processAutoGrant(pool, now, dayOfWeek, dayOfMonth);
if (processed) {
successCount++;
} else {
skipCount++;
}
} catch (Exception e) {
errorCount++;
log.error("自动发放积分失败: deptId={}, error={}", pool.getDeptId(), e.getMessage(), e);
}
}
log.info("积分自动发放定时任务完成: 成功={}, 跳过={}, 失败={}", successCount, skipCount, errorCount);
}
/**
* 处理单个积分池的自动发放
* @return true-已执行发放, false-未执行(不满足条件)
*/
private boolean processAutoGrant(TeamScorePool pool, LocalDateTime now,
int dayOfWeek, int dayOfMonth) {
String period = pool.getAutoGrantPeriod();
Integer grantDay = pool.getAutoGrantDay();
if (period == null || grantDay == null) {
return false;
}
// 检查是否是发放日
boolean shouldGrant = false;
if ("WEEKLY".equals(period) && dayOfWeek == grantDay) {
shouldGrant = true;
} else if ("MONTHLY".equals(period) && dayOfMonth == grantDay) {
shouldGrant = true;
}
if (!shouldGrant) {
return false;
}
// 检查是否已经发放过(防止重复发放)
if (pool.getLastGrantTime() != null) {
if ("WEEKLY".equals(period)) {
// 本周是否已发放:检查上次发放是否在本周
LocalDateTime weekStart = now.with(DayOfWeek.MONDAY).withHour(0).withMinute(0).withSecond(0).withNano(0);
if (pool.getLastGrantTime().isAfter(weekStart)) {
log.debug("部门 {} 本周已发放,跳过", pool.getDeptId());
return false;
}
} else if ("MONTHLY".equals(period)) {
// 本月是否已发放
if (pool.getLastGrantTime().getMonthValue() == now.getMonthValue() &&
pool.getLastGrantTime().getYear() == now.getYear()) {
log.debug("部门 {} 本月已发放,跳过", pool.getDeptId());
return false;
}
}
}
// 执行发放
executeGrant(pool, now, period);
return true;
}
/**
* 执行积分发放
*/
@Transactional
public void executeGrant(TeamScorePool pool, LocalDateTime now, String period) {
int balanceBefore = pool.getBalance() != null ? pool.getBalance() : 0;
int expiredAmount = 0;
// 如果启用过期清零,先清零旧积分
if (pool.getExpireUnused() != null && pool.getExpireUnused() == 1 && balanceBefore > 0) {
expiredAmount = balanceBefore;
pool.setBalance(0);
log.info("部门 {} 过期清零 {} 积分", pool.getDeptId(), expiredAmount);
}
// 发放新积分
int grantAmount = pool.getAutoGrantAmount() != null ? pool.getAutoGrantAmount() : 0;
int currentBalance = pool.getBalance() != null ? pool.getBalance() : 0;
pool.setBalance(currentBalance + grantAmount);
// 更新累计充值
int totalRecharge = pool.getTotalRecharge() != null ? pool.getTotalRecharge() : 0;
pool.setTotalRecharge(totalRecharge + grantAmount);
// 更新发放时间
pool.setLastGrantTime(now);
pool.setNextGrantTime(calculateNextGrantTime(now, period, pool.getAutoGrantDay()));
pool.setUpdateTime(now);
// 重置周期使用量
pool.setPeriodUsed(0);
pool.setPeriodStartTime(now);
teamScorePoolMapper.updateById(pool);
// 获取部门名称
String deptName = pool.getDeptName();
if (deptName == null) {
Department dept = departmentMapper.selectById(pool.getDeptId());
if (dept != null) {
deptName = dept.getDeptName();
}
}
// 记录发放历史
ScoreGrantRecord record = new ScoreGrantRecord();
record.setDeptId(pool.getDeptId());
record.setDeptName(deptName);
record.setGrantType("AUTO");
record.setGrantAmount(grantAmount);
record.setExpiredAmount(expiredAmount);
record.setBalanceBefore(balanceBefore);
record.setBalanceAfter(pool.getBalance());
record.setGrantPeriod(period);
record.setPeriodStart(calculatePeriodStart(now, period));
record.setPeriodEnd(calculatePeriodEnd(now, period));
record.setRemark(String.format("自动发放%d积分%s",
grantAmount,
expiredAmount > 0 ? ",过期清零" + expiredAmount + "积分" : ""));
record.setCreateTime(now);
scoreGrantRecordMapper.insert(record);
log.info("自动发放积分成功: deptId={}, deptName={}, amount={}, expired={}, newBalance={}",
pool.getDeptId(), deptName, grantAmount, expiredAmount, pool.getBalance());
// 发送通知给部门负责人
sendGrantNotification(pool, grantAmount, expiredAmount, deptName);
}
/**
* 手动触发积分发放(用于测试或补发)
*/
@Transactional
public void manualGrant(Long deptId) {
TeamScorePool pool = teamScorePoolMapper.selectOne(
new LambdaQueryWrapper<TeamScorePool>().eq(TeamScorePool::getDeptId, deptId)
);
if (pool == null || pool.getAutoGrantEnabled() == null || pool.getAutoGrantEnabled() != 1) {
throw new RuntimeException("该部门未启用自动发放功能");
}
executeGrant(pool, LocalDateTime.now(), pool.getAutoGrantPeriod());
}
/**
* 计算下次发放时间
*/
private LocalDateTime calculateNextGrantTime(LocalDateTime now, String period, Integer grantDay) {
if ("WEEKLY".equals(period)) {
// 下周的发放日
LocalDateTime nextWeek = now.plusWeeks(1);
return nextWeek.with(DayOfWeek.of(grantDay)).withHour(1).withMinute(0).withSecond(0).withNano(0);
} else {
// 下月的发放日
LocalDateTime nextMonth = now.plusMonths(1);
int day = Math.min(grantDay, nextMonth.toLocalDate().lengthOfMonth());
return nextMonth.withDayOfMonth(day).withHour(1).withMinute(0).withSecond(0).withNano(0);
}
}
/**
* 计算周期开始日期
*/
private LocalDate calculatePeriodStart(LocalDateTime now, String period) {
if ("WEEKLY".equals(period)) {
return now.toLocalDate().with(DayOfWeek.MONDAY);
} else {
return now.toLocalDate().withDayOfMonth(1);
}
}
/**
* 计算周期结束日期
*/
private LocalDate calculatePeriodEnd(LocalDateTime now, String period) {
if ("WEEKLY".equals(period)) {
return now.toLocalDate().with(DayOfWeek.SUNDAY);
} else {
return now.toLocalDate().with(TemporalAdjusters.lastDayOfMonth());
}
}
/**
* 发送发放通知
*/
private void sendGrantNotification(TeamScorePool pool, int grantAmount, int expiredAmount, String deptName) {
try {
// 查找部门负责人
Department dept = departmentMapper.selectById(pool.getDeptId());
if (dept != null && dept.getLeaderId() != null) {
String title = "积分自动发放通知";
String content;
if (expiredAmount > 0) {
content = String.format("您的部门「%s」已自动发放%d积分,上期未使用的%d积分已清零,当前余额%d积分。",
deptName, grantAmount, expiredAmount, pool.getBalance());
} else {
content = String.format("您的部门「%s」已自动发放%d积分,当前余额%d积分。",
deptName, grantAmount, pool.getBalance());
}
notificationService.sendNotification(
dept.getLeaderId(),
title,
content,
"score_grant",
pool.getId()
);
}
} catch (Exception e) {
log.warn("发送积分发放通知失败: {}", e.getMessage());
}
}
/**
* 获取部门的发放记录
*/
public List<ScoreGrantRecord> getGrantRecords(Long deptId, int limit) {
return scoreGrantRecordMapper.selectByDeptId(deptId, limit);
}
}

View File

@ -279,6 +279,71 @@ public class TeamScorePoolService {
teamScorePoolMapper.updateById(pool);
}
/**
* 更新积分池的自动发放配置
*/
@Transactional(rollbackFor = Exception.class)
public void updateAutoGrantConfig(Long deptId, Integer autoGrantEnabled, String autoGrantPeriod,
Integer autoGrantAmount, Integer autoGrantDay, Integer expireUnused) {
TeamScorePool pool = upsertPool(deptId, null);
if (autoGrantEnabled != null) {
pool.setAutoGrantEnabled(autoGrantEnabled);
}
if (autoGrantPeriod != null) {
pool.setAutoGrantPeriod(autoGrantPeriod);
}
if (autoGrantAmount != null) {
pool.setAutoGrantAmount(autoGrantAmount);
}
if (autoGrantDay != null) {
pool.setAutoGrantDay(autoGrantDay);
}
if (expireUnused != null) {
pool.setExpireUnused(expireUnused);
}
// 如果启用自动发放且没有设置下次发放时间,计算下次发放时间
if (autoGrantEnabled != null && autoGrantEnabled == 1 && pool.getNextGrantTime() == null) {
pool.setNextGrantTime(calculateNextGrantTime(autoGrantPeriod, autoGrantDay));
}
teamScorePoolMapper.updateById(pool);
}
/**
* 计算下次发放时间
*/
private LocalDateTime calculateNextGrantTime(String period, Integer grantDay) {
LocalDateTime now = LocalDateTime.now();
int day = grantDay != null ? grantDay : 1;
if ("WEEKLY".equals(period)) {
// 计算下一个发放日(本周或下周)
int currentDayOfWeek = now.getDayOfWeek().getValue();
if (currentDayOfWeek < day) {
// 本周还没到发放日
return now.with(java.time.DayOfWeek.of(day)).withHour(1).withMinute(0).withSecond(0).withNano(0);
} else {
// 下周的发放日
return now.plusWeeks(1).with(java.time.DayOfWeek.of(day)).withHour(1).withMinute(0).withSecond(0).withNano(0);
}
} else {
// 计算下一个发放日(本月或下月)
int currentDayOfMonth = now.getDayOfMonth();
int targetDay = Math.min(day, now.toLocalDate().lengthOfMonth());
if (currentDayOfMonth < targetDay) {
// 本月还没到发放日
return now.withDayOfMonth(targetDay).withHour(1).withMinute(0).withSecond(0).withNano(0);
} else {
// 下月的发放日
LocalDateTime nextMonth = now.plusMonths(1);
int nextTargetDay = Math.min(day, nextMonth.toLocalDate().lengthOfMonth());
return nextMonth.withDayOfMonth(nextTargetDay).withHour(1).withMinute(0).withSecond(0).withNano(0);
}
}
}
/**
* 直接设置积分余额(用于初始化或同步)
*/

View File

@ -0,0 +1,61 @@
-- V14__score_pool_auto_grant.sql
-- 需求部门积分池自动发放功能数据库迁移脚本
-- 创建日期:2026-01-19
-- =====================================================
-- 1. 修改 team_score_pool 表,新增自动发放相关字段
-- =====================================================
-- 是否启用自动发放
ALTER TABLE team_score_pool ADD COLUMN auto_grant_enabled TINYINT DEFAULT 0 COMMENT '是否启用自动发放:0-否,1-是';
-- 自动发放周期
ALTER TABLE team_score_pool ADD COLUMN auto_grant_period VARCHAR(20) DEFAULT 'WEEKLY' COMMENT '自动发放周期:WEEKLY-每周,MONTHLY-每月';
-- 每次自动发放积分数量
ALTER TABLE team_score_pool ADD COLUMN auto_grant_amount INT DEFAULT 0 COMMENT '每次自动发放积分数量';
-- 发放日期
ALTER TABLE team_score_pool ADD COLUMN auto_grant_day INT DEFAULT 1 COMMENT '发放日期:周发放为1-7(周一到周日),月发放为1-28';
-- 过期未使用是否清零
ALTER TABLE team_score_pool ADD COLUMN expire_unused TINYINT DEFAULT 0 COMMENT '过期未使用是否清零:0-否,1-是';
-- 上次自动发放时间
ALTER TABLE team_score_pool ADD COLUMN last_grant_time DATETIME COMMENT '上次自动发放时间';
-- 下次自动发放时间
ALTER TABLE team_score_pool ADD COLUMN next_grant_time DATETIME COMMENT '下次自动发放时间';
-- =====================================================
-- 2. 创建积分发放记录表
-- =====================================================
CREATE TABLE IF NOT EXISTS score_grant_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
dept_id BIGINT NOT NULL COMMENT '部门ID',
dept_name VARCHAR(100) COMMENT '部门名称',
grant_type VARCHAR(20) NOT NULL COMMENT '发放类型:AUTO-自动发放,MANUAL-手动充值',
grant_amount INT NOT NULL COMMENT '发放积分数量',
expired_amount INT DEFAULT 0 COMMENT '过期清零积分数量',
balance_before INT COMMENT '发放前余额',
balance_after INT COMMENT '发放后余额',
grant_period VARCHAR(20) COMMENT '发放周期:WEEKLY/MONTHLY',
period_start DATE COMMENT '周期开始日期',
period_end DATE COMMENT '周期结束日期',
remark VARCHAR(500) COMMENT '备注',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_dept_id (dept_id),
INDEX idx_grant_type (grant_type),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分发放记录表';
-- =====================================================
-- 3. 为现有数据设置默认值
-- =====================================================
UPDATE team_score_pool SET auto_grant_enabled = 0 WHERE auto_grant_enabled IS NULL;
UPDATE team_score_pool SET auto_grant_period = 'WEEKLY' WHERE auto_grant_period IS NULL;
UPDATE team_score_pool SET auto_grant_amount = 0 WHERE auto_grant_amount IS NULL;
UPDATE team_score_pool SET auto_grant_day = 1 WHERE auto_grant_day IS NULL;
UPDATE team_score_pool SET expire_unused = 0 WHERE expire_unused IS NULL;

View File

@ -0,0 +1,350 @@
# 需求部门积分池增强方案
## 一、功能概述
为**需求部门**(包括普通需求部门和一级需求部门)的积分池增加以下功能:
1. **定时自动发放积分** - 支持按周/月自动发放固定积分值
2. **过期未使用清零** - 周期结束时未使用的积分自动清零
3. **积分发放记录** - 记录每次自动发放的历史
## 二、数据库设计
### 2.1 修改 `team_score_pool` 表
```sql
-- 新增字段
ALTER TABLE team_score_pool ADD COLUMN auto_grant_enabled TINYINT DEFAULT 0 COMMENT '是否启用自动发放:0-否,1-是';
ALTER TABLE team_score_pool ADD COLUMN auto_grant_period VARCHAR(20) DEFAULT 'WEEKLY' COMMENT '自动发放周期:WEEKLY-每周,MONTHLY-每月';
ALTER TABLE team_score_pool ADD COLUMN auto_grant_amount INT DEFAULT 0 COMMENT '每次自动发放积分数量';
ALTER TABLE team_score_pool ADD COLUMN auto_grant_day INT DEFAULT 1 COMMENT '发放日期:周发放为1-7(周一到周日),月发放为1-28';
ALTER TABLE team_score_pool ADD COLUMN expire_unused TINYINT DEFAULT 0 COMMENT '过期未使用是否清零:0-否,1-是';
ALTER TABLE team_score_pool ADD COLUMN last_grant_time DATETIME COMMENT '上次自动发放时间';
ALTER TABLE team_score_pool ADD COLUMN next_grant_time DATETIME COMMENT '下次自动发放时间';
```
### 2.2 新增积分发放记录表
```sql
CREATE TABLE IF NOT EXISTS score_grant_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
dept_id BIGINT NOT NULL COMMENT '部门ID',
grant_type VARCHAR(20) NOT NULL COMMENT '发放类型:AUTO-自动发放,MANUAL-手动充值',
grant_amount INT NOT NULL COMMENT '发放积分数量',
expired_amount INT DEFAULT 0 COMMENT '过期清零积分数量',
balance_before INT COMMENT '发放前余额',
balance_after INT COMMENT '发放后余额',
grant_period VARCHAR(20) COMMENT '发放周期:WEEKLY/MONTHLY',
period_start DATE COMMENT '周期开始日期',
period_end DATE COMMENT '周期结束日期',
remark VARCHAR(500) COMMENT '备注',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_dept_id (dept_id),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分发放记录表';
```
## 三、后端开发
### 3.1 实体类修改
**TeamScorePool.java 新增字段:**
```java
private Integer autoGrantEnabled; // 是否启用自动发放
private String autoGrantPeriod; // 自动发放周期:WEEKLY/MONTHLY
private Integer autoGrantAmount; // 每次自动发放积分数量
private Integer autoGrantDay; // 发放日期
private Integer expireUnused; // 过期未使用是否清零
private LocalDateTime lastGrantTime; // 上次自动发放时间
private LocalDateTime nextGrantTime; // 下次自动发放时间
```
### 3.2 新增实体类
**ScoreGrantRecord.java**
```java
@Data
@TableName("score_grant_record")
public class ScoreGrantRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long deptId;
private String grantType; // AUTO/MANUAL
private Integer grantAmount;
private Integer expiredAmount;
private Integer balanceBefore;
private Integer balanceAfter;
private String grantPeriod;
private LocalDate periodStart;
private LocalDate periodEnd;
private String remark;
private LocalDateTime createTime;
}
```
### 3.3 定时任务服务
**ScoreAutoGrantService.java**
```java
@Service
@Slf4j
public class ScoreAutoGrantService {
@Autowired
private TeamScorePoolMapper teamScorePoolMapper;
@Autowired
private ScoreGrantRecordMapper scoreGrantRecordMapper;
/**
* 定时任务:每天凌晨1点检查并执行自动发放
*/
@Scheduled(cron = "0 0 1 * * ?")
@Transactional
public void autoGrantScores() {
LocalDateTime now = LocalDateTime.now();
int dayOfWeek = now.getDayOfWeek().getValue(); // 1-7
int dayOfMonth = now.getDayOfMonth();
// 查询需要自动发放的积分池
LambdaQueryWrapper<TeamScorePool> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TeamScorePool::getAutoGrantEnabled, 1);
List<TeamScorePool> pools = teamScorePoolMapper.selectList(wrapper);
for (TeamScorePool pool : pools) {
try {
processAutoGrant(pool, now, dayOfWeek, dayOfMonth);
} catch (Exception e) {
log.error("自动发放积分失败: deptId={}", pool.getDeptId(), e);
}
}
}
private void processAutoGrant(TeamScorePool pool, LocalDateTime now,
int dayOfWeek, int dayOfMonth) {
String period = pool.getAutoGrantPeriod();
int grantDay = pool.getAutoGrantDay();
boolean shouldGrant = false;
if ("WEEKLY".equals(period) && dayOfWeek == grantDay) {
shouldGrant = true;
} else if ("MONTHLY".equals(period) && dayOfMonth == grantDay) {
shouldGrant = true;
}
if (!shouldGrant) return;
// 检查是否已经发放过
if (pool.getLastGrantTime() != null) {
if ("WEEKLY".equals(period)) {
// 本周是否已发放
LocalDateTime weekStart = now.with(DayOfWeek.MONDAY).withHour(0).withMinute(0).withSecond(0);
if (pool.getLastGrantTime().isAfter(weekStart)) {
return; // 本周已发放
}
} else if ("MONTHLY".equals(period)) {
// 本月是否已发放
if (pool.getLastGrantTime().getMonthValue() == now.getMonthValue() &&
pool.getLastGrantTime().getYear() == now.getYear()) {
return; // 本月已发放
}
}
}
// 执行发放
int balanceBefore = pool.getBalance();
int expiredAmount = 0;
// 如果启用过期清零,先清零旧积分
if (pool.getExpireUnused() == 1 && balanceBefore > 0) {
expiredAmount = balanceBefore;
pool.setBalance(0);
}
// 发放新积分
int grantAmount = pool.getAutoGrantAmount();
pool.setBalance(pool.getBalance() + grantAmount);
pool.setTotalRecharge(pool.getTotalRecharge() + grantAmount);
pool.setLastGrantTime(now);
pool.setNextGrantTime(calculateNextGrantTime(now, period, grantDay));
teamScorePoolMapper.updateById(pool);
// 记录发放历史
ScoreGrantRecord record = new ScoreGrantRecord();
record.setDeptId(pool.getDeptId());
record.setGrantType("AUTO");
record.setGrantAmount(grantAmount);
record.setExpiredAmount(expiredAmount);
record.setBalanceBefore(balanceBefore);
record.setBalanceAfter(pool.getBalance());
record.setGrantPeriod(period);
record.setPeriodStart(calculatePeriodStart(now, period));
record.setPeriodEnd(calculatePeriodEnd(now, period));
record.setRemark(String.format("自动发放%s积分,过期清零%s积分",
grantAmount, expiredAmount));
record.setCreateTime(now);
scoreGrantRecordMapper.insert(record);
log.info("自动发放积分成功: deptId={}, amount={}, expired={}",
pool.getDeptId(), grantAmount, expiredAmount);
}
private LocalDateTime calculateNextGrantTime(LocalDateTime now, String period, int grantDay) {
if ("WEEKLY".equals(period)) {
return now.plusWeeks(1).with(DayOfWeek.of(grantDay)).withHour(1).withMinute(0);
} else {
return now.plusMonths(1).withDayOfMonth(grantDay).withHour(1).withMinute(0);
}
}
}
```
## 四、前端开发
### 4.1 部门编辑弹窗增加配置
在部门编辑弹窗中,当部门类型为 `demand` 或 `primary_demand` 时,显示积分自动发放配置:
```vue
<!-- 积分自动发放配置(仅需求部门和一级需求部门) -->
<a-divider v-if="isDemandDept">积分自动发放配置</a-divider>
<template v-if="isDemandDept">
<a-form-item label="启用自动发放">
<a-switch v-model:checked="formData.department.autoGrantEnabled" />
</a-form-item>
<template v-if="formData.department.autoGrantEnabled">
<a-form-item label="发放周期">
<a-radio-group v-model:value="formData.department.autoGrantPeriod">
<a-radio value="WEEKLY">每周发放</a-radio>
<a-radio value="MONTHLY">每月发放</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="发放日期">
<a-select v-model:value="formData.department.autoGrantDay">
<template v-if="formData.department.autoGrantPeriod === 'WEEKLY'">
<a-select-option :value="1">周一</a-select-option>
<a-select-option :value="2">周二</a-select-option>
<a-select-option :value="3">周三</a-select-option>
<a-select-option :value="4">周四</a-select-option>
<a-select-option :value="5">周五</a-select-option>
<a-select-option :value="6">周六</a-select-option>
<a-select-option :value="7">周日</a-select-option>
</template>
<template v-else>
<a-select-option v-for="d in 28" :key="d" :value="d">{{ d }}号</a-select-option>
</template>
</a-select>
</a-form-item>
<a-form-item label="发放积分数量">
<a-input-number
v-model:value="formData.department.autoGrantAmount"
:min="0"
:max="100000"
placeholder="每次发放的积分数量"
style="width: 200px;"
/>
</a-form-item>
<a-form-item label="过期清零">
<a-switch v-model:checked="formData.department.expireUnused" />
<span style="margin-left: 8px; color: #888;">
开启后,新积分发放时会清零上期未使用的积分
</span>
</a-form-item>
</template>
</template>
```
### 4.2 积分池弹窗显示发放记录
在积分池管理弹窗中增加"发放记录"tab:
```vue
<a-tabs>
<a-tab-pane key="overview" tab="积分概览">
<!-- 现有的积分概览内容 -->
</a-tab-pane>
<a-tab-pane key="grant-records" tab="发放记录">
<a-table
:columns="grantRecordColumns"
:data-source="grantRecords"
:pagination="{ pageSize: 10 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'grantType'">
<a-tag :color="record.grantType === 'AUTO' ? 'blue' : 'green'">
{{ record.grantType === 'AUTO' ? '自动发放' : '手动充值' }}
</a-tag>
</template>
<template v-else-if="column.key === 'expiredAmount'">
<span v-if="record.expiredAmount > 0" style="color: #ff4d4f;">
-{{ record.expiredAmount }}
</span>
<span v-else>--</span>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
```
## 五、使用场景示例
### 场景1:每周发放固定积分,过期清零
**配置:**
- 自动发放:开启
- 发放周期:每周
- 发放日期:周一
- 发放积分:500分
- 过期清零:开启
**效果:**
- 每周一凌晨自动发放500积分
- 上周未使用的积分自动清零
- 部门每周有固定500积分预算发起任务
### 场景2:每月发放积分,累积使用
**配置:**
- 自动发放:开启
- 发放周期:每月
- 发放日期:1号
- 发放积分:2000分
- 过期清零:关闭
**效果:**
- 每月1号自动发放2000积分
- 未使用的积分累积到下月
- 部门可以灵活使用积分
## 六、开发工时估算
| 阶段 | 内容 | 工时 |
|------|------|------|
| 数据库 | 表结构修改和新建 | 0.5天 |
| 后端 | 实体类、Service、定时任务 | 1天 |
| 后端 | API接口开发 | 0.5天 |
| 前端 | 部门编辑弹窗修改 | 0.5天 |
| 前端 | 积分池弹窗增强 | 0.5天 |
| 测试 | 功能测试和联调 | 0.5天 |
| **合计** | | **3.5天** |
## 七、注意事项
1. 定时任务需要确保服务器时间准确
2. 过期清零会影响部门的任务发起能力,需要提前通知
3. 建议在发放前一天发送提醒通知
4. 需要考虑服务重启时定时任务的补偿机制
---
**是否需要我开始实现这个功能?**

View File

@ -62,6 +62,14 @@ export interface TeamScorePool {
periodUsed?: number
periodStartTime?: string
alertThreshold?: number
// 自动发放相关字段
autoGrantEnabled?: number
autoGrantPeriod?: string
autoGrantAmount?: number
autoGrantDay?: number
expireUnused?: number
lastGrantTime?: string
nextGrantTime?: string
}
export interface TaskSubType {

View File

@ -142,3 +142,50 @@ export const getDemandDeptByBrandId = (brandId: number) => {
export const getPrimaryDemandDepartments = () => {
return request.get<DepartmentOverview[]>('/departments/primary-demand')
}
// ==================== 积分发放记录 ====================
export interface ScoreGrantRecord {
id: number
deptId: number
deptName?: string
grantType: 'AUTO' | 'MANUAL'
grantAmount: number
expiredAmount: number
balanceBefore: number
balanceAfter: number
grantPeriod?: string
periodStart?: string
periodEnd?: string
remark?: string
createTime: string
}
// 获取部门积分发放记录
export const getGrantRecords = (deptId: number, limit: number = 50) => {
return request.get<ScoreGrantRecord[]>(`/departments/${deptId}/grant-records`, { params: { limit } })
}
// 手动触发积分发放
export const manualGrant = (deptId: number) => {
return request.post(`/departments/${deptId}/manual-grant`)
}
// 更新部门积分池自动发放配置
export interface AutoGrantConfig {
autoGrantEnabled?: number
autoGrantPeriod?: 'WEEKLY' | 'MONTHLY'
autoGrantAmount?: number
autoGrantDay?: number
expireUnused?: number
}
export const updateAutoGrantConfig = (deptId: number, config: AutoGrantConfig) => {
const params = new URLSearchParams()
if (config.autoGrantEnabled !== undefined) params.append('autoGrantEnabled', String(config.autoGrantEnabled))
if (config.autoGrantPeriod) params.append('autoGrantPeriod', config.autoGrantPeriod)
if (config.autoGrantAmount !== undefined) params.append('autoGrantAmount', String(config.autoGrantAmount))
if (config.autoGrantDay !== undefined) params.append('autoGrantDay', String(config.autoGrantDay))
if (config.expireUnused !== undefined) params.append('expireUnused', String(config.expireUnused))
return request.put(`/departments/${deptId}/auto-grant-config?${params.toString()}`)
}

View File

@ -2083,6 +2083,62 @@
placeholder="数字越小越靠前"
/>
</a-form-item>
<!-- 积分自动发放配置(仅需求部门和一级需求部门) -->
<template v-if="isDemandDept">
<a-divider>积分自动发放配置</a-divider>
<a-form-item label="启用自动发放">
<a-switch v-model:checked="formData.department.autoGrantEnabled" />
<span style="margin-left: 8px; color: #888; font-size: 12px;">
开启后系统将按设定周期自动发放积分
</span>
</a-form-item>
<template v-if="formData.department.autoGrantEnabled">
<a-form-item label="发放周期">
<a-radio-group v-model:value="formData.department.autoGrantPeriod">
<a-radio value="WEEKLY">每周发放</a-radio>
<a-radio value="MONTHLY">每月发放</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="发放日期">
<a-select v-model:value="formData.department.autoGrantDay" style="width: 200px;">
<template v-if="formData.department.autoGrantPeriod === 'WEEKLY'">
<a-select-option :value="1">周一</a-select-option>
<a-select-option :value="2">周二</a-select-option>
<a-select-option :value="3">周三</a-select-option>
<a-select-option :value="4">周四</a-select-option>
<a-select-option :value="5">周五</a-select-option>
<a-select-option :value="6">周六</a-select-option>
<a-select-option :value="7">周日</a-select-option>
</template>
<template v-else>
<a-select-option v-for="d in 28" :key="d" :value="d">{{ d }}号</a-select-option>
</template>
</a-select>
</a-form-item>
<a-form-item label="发放积分数量">
<a-input-number
v-model:value="formData.department.autoGrantAmount"
:min="0"
:max="100000"
placeholder="每次发放的积分数量"
style="width: 200px;"
/>
<span style="margin-left: 8px;">分</span>
</a-form-item>
<a-form-item label="过期清零">
<a-switch v-model:checked="formData.department.expireUnused" />
<span style="margin-left: 8px; color: #888; font-size: 12px;">
开启后,新积分发放时会清零上期未使用的积分
</span>
</a-form-item>
</template>
</template>
</a-form>
</a-modal>
@ -2204,6 +2260,71 @@
</a-form-item>
</a-form>
</template>
<!-- 自动发放配置显示 -->
<template v-if="currentScorePool.autoGrantEnabled === 1">
<a-divider>自动发放配置</a-divider>
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="发放周期">
{{ currentScorePool.autoGrantPeriod === 'WEEKLY' ? '每周' : '每月' }}
</a-descriptions-item>
<a-descriptions-item label="发放日期">
{{ currentScorePool.autoGrantPeriod === 'WEEKLY'
? ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'][currentScorePool.autoGrantDay || 1]
: (currentScorePool.autoGrantDay || 1) + '号' }}
</a-descriptions-item>
<a-descriptions-item label="发放积分">
{{ currentScorePool.autoGrantAmount || 0 }} 分/次
</a-descriptions-item>
<a-descriptions-item label="过期清零">
<a-tag :color="currentScorePool.expireUnused === 1 ? 'orange' : 'default'">
{{ currentScorePool.expireUnused === 1 ? '是' : '否' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="上次发放" v-if="currentScorePool.lastGrantTime">
{{ formatDateTime(currentScorePool.lastGrantTime) }}
</a-descriptions-item>
<a-descriptions-item label="下次发放" v-if="currentScorePool.nextGrantTime">
{{ formatDateTime(currentScorePool.nextGrantTime) }}
</a-descriptions-item>
</a-descriptions>
<div style="margin-top: 16px;">
<a-button type="default" @click="handleManualGrant" :loading="manualGrantLoading">
手动触发发放
</a-button>
<span style="margin-left: 8px; color: #888; font-size: 12px;">
(用于测试或补发)
</span>
</div>
</template>
<!-- 发放记录 -->
<a-divider>积分发放记录</a-divider>
<a-table
:columns="grantRecordColumns"
:data-source="grantRecords"
:loading="loadingGrantRecords"
:pagination="{ pageSize: 5 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'grantType'">
<a-tag :color="record.grantType === 'AUTO' ? 'blue' : 'green'">
{{ record.grantType === 'AUTO' ? '自动发放' : '手动充值' }}
</a-tag>
</template>
<template v-else-if="column.key === 'createTime'">
{{ formatDateTime(record.createTime) }}
</template>
<template v-else-if="column.key === 'amounts'">
<span style="color: #52c41a;">+{{ record.grantAmount }}</span>
<span v-if="record.expiredAmount > 0" style="color: #ff4d4f; margin-left: 8px;">
-{{ record.expiredAmount }}(过期)
</span>
</template>
</template>
</a-table>
</div>
<a-empty v-else description="加载中..." />
</a-modal>
@ -2438,6 +2559,12 @@ import 'dayjs/locale/zh-cn'
// 设置 dayjs 中文
dayjs.locale('zh-cn')
// 格式化日期时间
const formatDateTime = (dateStr: string | null | undefined): string => {
if (!dateStr) return '--'
return dayjs(dateStr).format('YYYY-MM-DD HH:mm')
}
// Ant Design 中文配置
const locale = zhCN
import Icon from '@/components/Icon.vue'
@ -2463,6 +2590,9 @@ import {
getDepartmentScorePool,
setDeptResponsibleBrands,
getDeptResponsibleBrandIds,
updateAutoGrantConfig,
getGrantRecords,
manualGrant,
type DepartmentOverview,
type DepartmentPayload,
type ScoreAdjustPayload,
@ -2529,6 +2659,12 @@ interface DepartmentForm {
overLimitAction: 'alert' | 'block'
initialScoreBalance?: number
responsibleBrandIds?: number[] // 负责的品牌ID列表(仅需求部门)
// 积分自动发放配置
autoGrantEnabled?: boolean
autoGrantPeriod?: 'WEEKLY' | 'MONTHLY'
autoGrantAmount?: number
autoGrantDay?: number
expireUnused?: boolean
}
const activeTab = ref('demand-types')
@ -2605,6 +2741,20 @@ const scoreAdjustForm = reactive<ScoreAdjustPayload>({
})
const rechargeAmount = ref<number>(0)
// 积分发放记录相关
const grantRecords = ref<any[]>([])
const loadingGrantRecords = ref(false)
const manualGrantLoading = ref(false)
const grantRecordColumns = [
{ title: '发放类型', key: 'grantType', dataIndex: 'grantType', width: 100 },
{ title: '发放/过期积分', key: 'amounts', width: 150 },
{ title: '发放前余额', dataIndex: 'balanceBefore', width: 100 },
{ title: '发放后余额', dataIndex: 'balanceAfter', width: 100 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', key: 'createTime', dataIndex: 'createTime', width: 160 }
]
const userLoading = ref(false)
const usersLoaded = ref(false)
const usersByDept = ref<Record<number, User[]>>({})
@ -2749,7 +2899,13 @@ const defaultDepartmentForm = (): DepartmentForm => ({
alertThreshold: 80,
overLimitAction: 'alert',
initialScoreBalance: 0,
responsibleBrandIds: []
responsibleBrandIds: [],
// 积分自动发放配置
autoGrantEnabled: false,
autoGrantPeriod: 'WEEKLY',
autoGrantAmount: 500,
autoGrantDay: 1,
expireUnused: true
})
const formData = reactive({
@ -3645,6 +3801,9 @@ const showScorePoolModal = async (dept: DepartmentOverview) => {
scoreAdjustForm.remark = ''
rechargeAmount.value = 0
modals.scorePool = true
// 加载发放记录
loadGrantRecords(dept.id)
} else {
message.warning('该部门暂无积分池')
}
@ -3653,6 +3812,38 @@ const showScorePoolModal = async (dept: DepartmentOverview) => {
}
}
// 加载发放记录
const loadGrantRecords = async (deptId: number) => {
loadingGrantRecords.value = true
try {
const result = await getGrantRecords(deptId, 50)
grantRecords.value = result?.data || result || []
} catch (error) {
console.error('加载发放记录失败:', error)
grantRecords.value = []
} finally {
loadingGrantRecords.value = false
}
}
// 手动触发发放
const handleManualGrant = async () => {
if (!currentDept.value?.id) return
manualGrantLoading.value = true
try {
await manualGrant(currentDept.value.id)
message.success('手动发放成功')
// 刷新积分池信息和发放记录
await showScorePoolModal(currentDept.value)
await loadDepartments()
} catch (error: any) {
message.error(error?.response?.data?.message || '手动发放失败')
} finally {
manualGrantLoading.value = false
}
}
// 处理积分充值
const handleRecharge = async () => {
if (!currentDept.value?.id) {
@ -3876,7 +4067,30 @@ const showDepartmentModal = async (item?: DepartmentOverview) => {
alertThreshold: item.alertThreshold ?? 80,
overLimitAction: (item.overLimitAction as 'alert' | 'block') || 'alert',
initialScoreBalance: item.scoreBalance ?? 0,
responsibleBrandIds
responsibleBrandIds,
// 积分自动发放配置(从积分池获取)
autoGrantEnabled: false,
autoGrantPeriod: 'WEEKLY',
autoGrantAmount: 500,
autoGrantDay: 1,
expireUnused: true
}
// 加载积分池中的自动发放配置
if ((item.deptType === 'demand' || item.deptType === 'primary_demand') && item.id) {
try {
const poolResult = await getDepartmentScorePool(item.id)
const pool = poolResult?.data || poolResult
if (pool) {
formData.department.autoGrantEnabled = pool.autoGrantEnabled === 1
formData.department.autoGrantPeriod = pool.autoGrantPeriod || 'WEEKLY'
formData.department.autoGrantAmount = pool.autoGrantAmount || 500
formData.department.autoGrantDay = pool.autoGrantDay || 1
formData.department.expireUnused = pool.expireUnused === 1
}
} catch (e) {
console.warn('加载积分池配置失败:', e)
}
}
} else {
formData.department = defaultDepartmentForm()
@ -4126,6 +4340,22 @@ const saveDepartment = async () => {
}
}
// 如果是需求部门或一级需求部门,保存自动发放配置
if ((formData.department.deptType === 'demand' || formData.department.deptType === 'primary_demand') && deptId) {
try {
await updateAutoGrantConfig(deptId, {
autoGrantEnabled: formData.department.autoGrantEnabled ? 1 : 0,
autoGrantPeriod: formData.department.autoGrantPeriod,
autoGrantAmount: formData.department.autoGrantAmount,
autoGrantDay: formData.department.autoGrantDay,
expireUnused: formData.department.expireUnused ? 1 : 0
})
} catch (grantError: any) {
console.error('保存自动发放配置失败:', grantError)
message.warning('部门保存成功,但自动发放配置保存失败:' + (grantError?.response?.data?.message || grantError?.message))
}
}
closeModal('department')
await loadDepartments()
} catch (error: any) {