diff --git a/backend/src/main/java/com/designhub/controller/DepartmentController.java b/backend/src/main/java/com/designhub/controller/DepartmentController.java index 6ea99c0..82cc7ef 100644 --- a/backend/src/main/java/com/designhub/controller/DepartmentController.java +++ b/backend/src/main/java/com/designhub/controller/DepartmentController.java @@ -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 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> getPrimaryDemandDepartments() { return Result.success(departmentService.getPrimaryDemandDepartments()); } + + // ==================== 积分发放记录 ==================== + + @Operation(summary = "获取部门积分发放记录") + @GetMapping("/{deptId}/grant-records") + public Result> getGrantRecords( + @PathVariable Long deptId, + @RequestParam(defaultValue = "50") int limit) { + return Result.success(scoreAutoGrantService.getGrantRecords(deptId, limit)); + } + + @Operation(summary = "手动触发积分发放(测试/补发)") + @PostMapping("/{deptId}/manual-grant") + public Result manualGrant(@PathVariable Long deptId) { + scoreAutoGrantService.manualGrant(deptId); + return Result.success(); + } } diff --git a/backend/src/main/java/com/designhub/entity/ScoreGrantRecord.java b/backend/src/main/java/com/designhub/entity/ScoreGrantRecord.java new file mode 100644 index 0000000..bf6c169 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/ScoreGrantRecord.java @@ -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; +} diff --git a/backend/src/main/java/com/designhub/entity/TeamScorePool.java b/backend/src/main/java/com/designhub/entity/TeamScorePool.java index 57e60d8..ad3cddb 100644 --- a/backend/src/main/java/com/designhub/entity/TeamScorePool.java +++ b/backend/src/main/java/com/designhub/entity/TeamScorePool.java @@ -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; + /** * 创建时间 */ diff --git a/backend/src/main/java/com/designhub/mapper/ScoreGrantRecordMapper.java b/backend/src/main/java/com/designhub/mapper/ScoreGrantRecordMapper.java new file mode 100644 index 0000000..5e0f51a --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/ScoreGrantRecordMapper.java @@ -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 { + + /** + * 根据部门ID查询发放记录 + */ + @Select("SELECT * FROM score_grant_record WHERE dept_id = #{deptId} ORDER BY create_time DESC LIMIT #{limit}") + List 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); +} diff --git a/backend/src/main/java/com/designhub/service/ScoreAutoGrantService.java b/backend/src/main/java/com/designhub/service/ScoreAutoGrantService.java new file mode 100644 index 0000000..cf79038 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/ScoreAutoGrantService.java @@ -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 wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(TeamScorePool::getAutoGrantEnabled, 1); + + List 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().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 getGrantRecords(Long deptId, int limit) { + return scoreGrantRecordMapper.selectByDeptId(deptId, limit); + } +} diff --git a/backend/src/main/java/com/designhub/service/TeamScorePoolService.java b/backend/src/main/java/com/designhub/service/TeamScorePoolService.java index a3925f9..7a55bf8 100644 --- a/backend/src/main/java/com/designhub/service/TeamScorePoolService.java +++ b/backend/src/main/java/com/designhub/service/TeamScorePoolService.java @@ -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); + } + } + } + /** * 直接设置积分余额(用于初始化或同步) */ diff --git a/database/V14__score_pool_auto_grant.sql b/database/V14__score_pool_auto_grant.sql new file mode 100644 index 0000000..367e1c7 --- /dev/null +++ b/database/V14__score_pool_auto_grant.sql @@ -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; diff --git a/docs/需求部门积分池增强方案.md b/docs/需求部门积分池增强方案.md new file mode 100644 index 0000000..9d77184 --- /dev/null +++ b/docs/需求部门积分池增强方案.md @@ -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 wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(TeamScorePool::getAutoGrantEnabled, 1); + + List 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 + +积分自动发放配置 + +``` + +### 4.2 积分池弹窗显示发放记录 + +在积分池管理弹窗中增加"发放记录"tab: + +```vue + + + + + + + + + + + +``` + +## 五、使用场景示例 + +### 场景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. 需要考虑服务重启时定时任务的补偿机制 + +--- + +**是否需要我开始实现这个功能?** diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 255ade8..0c663eb 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -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 { diff --git a/frontend/src/api/department.ts b/frontend/src/api/department.ts index dd8887a..226c0f9 100644 --- a/frontend/src/api/department.ts +++ b/frontend/src/api/department.ts @@ -142,3 +142,50 @@ export const getDemandDeptByBrandId = (brandId: number) => { export const getPrimaryDemandDepartments = () => { return request.get('/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(`/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()}`) +} diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 1cebf95..3078a17 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -2083,6 +2083,62 @@ placeholder="数字越小越靠前" /> + + + @@ -2204,6 +2260,71 @@ + + + + + + 积分发放记录 + + + @@ -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({ }) const rechargeAmount = ref(0) +// 积分发放记录相关 +const grantRecords = ref([]) +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>({}) @@ -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) {