feat: 奖励系统升级 - 添加奖励记录、徽章、称号功能

This commit is contained in:
Your Name 2026-01-04 11:43:57 +08:00
parent 43d13d1a95
commit 91e3dc3eaa
18 changed files with 2228 additions and 94 deletions

View File

@ -2,14 +2,18 @@ package com.designhub.controller;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.designhub.common.Result; import com.designhub.common.Result;
import com.designhub.entity.RewardRule; import com.designhub.entity.*;
import com.designhub.service.RewardRecordService;
import com.designhub.service.RewardRuleService; import com.designhub.service.RewardRuleService;
import com.designhub.util.JwtUtil; import com.designhub.util.JwtUtil;
import com.designhub.vo.RewardStatsVO;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
/** /**
@ -22,6 +26,7 @@ import java.util.List;
public class RewardRuleController { public class RewardRuleController {
private final RewardRuleService rewardRuleService; private final RewardRuleService rewardRuleService;
private final RewardRecordService rewardRecordService;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
@Operation(summary = "创建奖励规则") @Operation(summary = "创建奖励规则")
@ -89,4 +94,101 @@ public class RewardRuleController {
rewardRuleService.initDefaultRules(); rewardRuleService.initDefaultRules();
return Result.success(); return Result.success();
} }
// ==================== 奖励统计 ====================
@Operation(summary = "获取奖励统计数据")
@GetMapping("/stats")
public Result<RewardStatsVO> getStats(@RequestParam(defaultValue = "month") String period) {
return Result.success(rewardRecordService.getStats(period));
}
// ==================== 奖励记录 ====================
@Operation(summary = "获取奖励发放记录")
@GetMapping("/records")
public Result<IPage<RewardRecord>> getRecords(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) Long ruleId,
@RequestParam(required = false) String rewardType,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
return Result.success(rewardRecordService.getRecordList(page, pageSize, userId, ruleId, rewardType, startTime, endTime));
}
@Operation(summary = "获取我的奖励记录")
@GetMapping("/records/my")
public Result<IPage<RewardRecord>> getMyRecords(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize,
@RequestHeader("Authorization") String authHeader) {
String token = jwtUtil.extractTokenFromHeader(authHeader);
Long userId = jwtUtil.getUserIdFromToken(token);
return Result.success(rewardRecordService.getMyRecords(userId, page, pageSize));
}
// ==================== 徽章管理 ====================
@Operation(summary = "获取所有可用徽章")
@GetMapping("/badges")
public Result<List<BadgeDefinition>> getAllBadges() {
return Result.success(rewardRecordService.getAllBadges());
}
@Operation(summary = "获取用户徽章")
@GetMapping("/users/{userId}/badges")
public Result<List<UserBadge>> getUserBadges(@PathVariable Long userId) {
return Result.success(rewardRecordService.getUserBadges(userId));
}
@Operation(summary = "获取我的徽章")
@GetMapping("/badges/my")
public Result<List<UserBadge>> getMyBadges(@RequestHeader("Authorization") String authHeader) {
String token = jwtUtil.extractTokenFromHeader(authHeader);
Long userId = jwtUtil.getUserIdFromToken(token);
return Result.success(rewardRecordService.getUserBadges(userId));
}
// ==================== 称号管理 ====================
@Operation(summary = "获取所有可用称号")
@GetMapping("/titles")
public Result<List<TitleDefinition>> getAllTitles() {
return Result.success(rewardRecordService.getAllTitles());
}
@Operation(summary = "获取用户称号")
@GetMapping("/users/{userId}/titles")
public Result<List<UserTitle>> getUserTitles(@PathVariable Long userId) {
return Result.success(rewardRecordService.getUserTitles(userId));
}
@Operation(summary = "获取我的称号")
@GetMapping("/titles/my")
public Result<List<UserTitle>> getMyTitles(@RequestHeader("Authorization") String authHeader) {
String token = jwtUtil.extractTokenFromHeader(authHeader);
Long userId = jwtUtil.getUserIdFromToken(token);
return Result.success(rewardRecordService.getUserTitles(userId));
}
@Operation(summary = "设置激活称号")
@PutMapping("/titles/active")
public Result<Void> setActiveTitle(
@RequestParam(required = false) Long titleId,
@RequestHeader("Authorization") String authHeader) {
String token = jwtUtil.extractTokenFromHeader(authHeader);
Long userId = jwtUtil.getUserIdFromToken(token);
rewardRecordService.setActiveTitle(userId, titleId);
return Result.success();
}
@Operation(summary = "获取我的激活称号")
@GetMapping("/titles/active")
public Result<UserTitle> getMyActiveTitle(@RequestHeader("Authorization") String authHeader) {
String token = jwtUtil.extractTokenFromHeader(authHeader);
Long userId = jwtUtil.getUserIdFromToken(token);
return Result.success(rewardRecordService.getActiveTitle(userId));
}
} }

View File

@ -0,0 +1,67 @@
package com.designhub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 徽章定义实体
*/
@Data
@TableName("badge_definition")
public class BadgeDefinition implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/**
* 徽章代码
*/
private String badgeCode;
/**
* 徽章名称
*/
private String badgeName;
/**
* 徽章图标URL
*/
private String badgeIcon;
/**
* 徽章颜色
*/
private String badgeColor;
/**
* 徽章描述
*/
private String description;
/**
* 分类:general/task/rating/special
*/
private String category;
/**
* 排序
*/
private Integer sortOrder;
/**
* 是否启用
*/
private Integer enabled;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@ -0,0 +1,65 @@
package com.designhub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 奖励发放记录实体
*/
@Data
@TableName("reward_record")
public class RewardRecord implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 规则ID
*/
private Long ruleId;
/**
* 规则名称(冗余)
*/
private String ruleName;
/**
* 奖励类型:score/exp/badge/title
*/
private String rewardType;
/**
* 奖励内容JSON
*/
private String rewardValue;
/**
* 触发时的数据快照
*/
private String triggerData;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
// ========== 非数据库字段 ==========
@TableField(exist = false)
private String userName;
@TableField(exist = false)
private String userAvatar;
}

View File

@ -0,0 +1,62 @@
package com.designhub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 称号定义实体
*/
@Data
@TableName("title_definition")
public class TitleDefinition implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/**
* 称号代码
*/
private String titleCode;
/**
* 称号名称
*/
private String titleName;
/**
* 称号颜色
*/
private String titleColor;
/**
* 称号描述
*/
private String description;
/**
* 分类
*/
private String category;
/**
* 排序
*/
private Integer sortOrder;
/**
* 是否启用
*/
private Integer enabled;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@ -0,0 +1,67 @@
package com.designhub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户徽章实体
*/
@Data
@TableName("user_badge")
public class UserBadge implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 徽章代码
*/
private String badgeCode;
/**
* 徽章名称
*/
private String badgeName;
/**
* 徽章图标URL
*/
private String badgeIcon;
/**
* 徽章颜色
*/
private String badgeColor;
/**
* 徽章描述
*/
private String description;
/**
* 来源类型:rule/manual/achievement
*/
private String sourceType;
/**
* 来源ID
*/
private Long sourceId;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@ -0,0 +1,72 @@
package com.designhub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户称号实体
*/
@Data
@TableName("user_title")
public class UserTitle implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 称号代码
*/
private String titleCode;
/**
* 称号名称
*/
private String titleName;
/**
* 称号颜色
*/
private String titleColor;
/**
* 称号描述
*/
private String description;
/**
* 是否激活显示:0-否 1-是
*/
private Integer isActive;
/**
* 过期时间(NULL=永久)
*/
private LocalDateTime expireTime;
/**
* 来源类型:rule/manual/achievement
*/
private String sourceType;
/**
* 来源ID
*/
private Long sourceId;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@ -0,0 +1,35 @@
package com.designhub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.designhub.entity.BadgeDefinition;
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 BadgeDefinitionMapper extends BaseMapper<BadgeDefinition> {
/**
* 获取所有启用的徽章
*/
@Select("SELECT * FROM badge_definition WHERE enabled = 1 ORDER BY sort_order, id")
List<BadgeDefinition> selectAllEnabled();
/**
* 根据代码获取徽章
*/
@Select("SELECT * FROM badge_definition WHERE badge_code = #{badgeCode} AND enabled = 1")
BadgeDefinition selectByCode(@Param("badgeCode") String badgeCode);
/**
* 按分类获取徽章
*/
@Select("SELECT * FROM badge_definition WHERE category = #{category} AND enabled = 1 ORDER BY sort_order")
List<BadgeDefinition> selectByCategory(@Param("category") String category);
}

View File

@ -0,0 +1,110 @@
package com.designhub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.designhub.entity.RewardRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 奖励发放记录Mapper
*/
@Mapper
public interface RewardRecordMapper extends BaseMapper<RewardRecord> {
/**
* 分页查询奖励记录(带用户信息)
*/
@Select("<script>" +
"SELECT r.*, u.real_name as user_name, u.avatar as user_avatar " +
"FROM reward_record r " +
"LEFT JOIN sys_user u ON r.user_id = u.id " +
"<where>" +
" <if test='userId != null'>AND r.user_id = #{userId}</if>" +
" <if test='ruleId != null'>AND r.rule_id = #{ruleId}</if>" +
" <if test='rewardType != null'>AND r.reward_type = #{rewardType}</if>" +
" <if test='startTime != null'>AND r.create_time >= #{startTime}</if>" +
" <if test='endTime != null'>AND r.create_time &lt;= #{endTime}</if>" +
"</where>" +
"ORDER BY r.create_time DESC" +
"</script>")
IPage<RewardRecord> selectPageWithUser(Page<RewardRecord> page,
@Param("userId") Long userId,
@Param("ruleId") Long ruleId,
@Param("rewardType") String rewardType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 统计用户某规则获得次数
*/
@Select("SELECT COUNT(*) FROM reward_record WHERE user_id = #{userId} AND rule_id = #{ruleId}")
int countByUserAndRule(@Param("userId") Long userId, @Param("ruleId") Long ruleId);
/**
* 统计时间范围内的奖励次数
*/
@Select("SELECT COUNT(*) FROM reward_record WHERE create_time >= #{startTime} AND create_time <= #{endTime}")
int countByTimeRange(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);
/**
* 统计时间范围内发放的总积分
*/
@Select("SELECT COALESCE(SUM(CAST(JSON_EXTRACT(reward_value, '$.score') AS SIGNED)), 0) " +
"FROM reward_record " +
"WHERE reward_type = 'score' AND create_time >= #{startTime} AND create_time <= #{endTime}")
int sumScoreByTimeRange(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);
/**
* 统计时间范围内的获奖用户数
*/
@Select("SELECT COUNT(DISTINCT user_id) FROM reward_record WHERE create_time >= #{startTime} AND create_time <= #{endTime}")
int countDistinctUsersByTimeRange(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);
/**
* 按规则统计触发次数(排行)
*/
@Select("SELECT rule_id, rule_name, COUNT(*) as trigger_count " +
"FROM reward_record " +
"WHERE create_time >= #{startTime} AND create_time <= #{endTime} AND rule_id IS NOT NULL " +
"GROUP BY rule_id, rule_name " +
"ORDER BY trigger_count DESC " +
"LIMIT #{limit}")
List<Map<String, Object>> getRuleRanking(@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("limit") int limit);
/**
* 按用户统计获奖积分(排行)
*/
@Select("SELECT r.user_id, u.real_name as user_name, u.avatar as user_avatar, " +
"COALESCE(SUM(CAST(JSON_EXTRACT(r.reward_value, '$.score') AS SIGNED)), 0) as total_score " +
"FROM reward_record r " +
"LEFT JOIN sys_user u ON r.user_id = u.id " +
"WHERE r.reward_type = 'score' AND r.create_time >= #{startTime} AND r.create_time <= #{endTime} " +
"GROUP BY r.user_id, u.real_name, u.avatar " +
"ORDER BY total_score DESC " +
"LIMIT #{limit}")
List<Map<String, Object>> getUserRanking(@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("limit") int limit);
/**
* 按天统计奖励趋势
*/
@Select("SELECT DATE(create_time) as date, COUNT(*) as count, " +
"COALESCE(SUM(CAST(JSON_EXTRACT(reward_value, '$.score') AS SIGNED)), 0) as total_score " +
"FROM reward_record " +
"WHERE create_time >= #{startTime} AND create_time <= #{endTime} " +
"GROUP BY DATE(create_time) " +
"ORDER BY date")
List<Map<String, Object>> getDailyTrend(@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
}

View File

@ -0,0 +1,35 @@
package com.designhub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.designhub.entity.TitleDefinition;
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 TitleDefinitionMapper extends BaseMapper<TitleDefinition> {
/**
* 获取所有启用的称号
*/
@Select("SELECT * FROM title_definition WHERE enabled = 1 ORDER BY sort_order, id")
List<TitleDefinition> selectAllEnabled();
/**
* 根据代码获取称号
*/
@Select("SELECT * FROM title_definition WHERE title_code = #{titleCode} AND enabled = 1")
TitleDefinition selectByCode(@Param("titleCode") String titleCode);
/**
* 按分类获取称号
*/
@Select("SELECT * FROM title_definition WHERE category = #{category} AND enabled = 1 ORDER BY sort_order")
List<TitleDefinition> selectByCategory(@Param("category") String category);
}

View File

@ -0,0 +1,29 @@
package com.designhub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.designhub.entity.UserBadge;
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 UserBadgeMapper extends BaseMapper<UserBadge> {
/**
* 获取用户的所有徽章
*/
@Select("SELECT * FROM user_badge WHERE user_id = #{userId} ORDER BY create_time DESC")
List<UserBadge> selectByUserId(@Param("userId") Long userId);
/**
* 检查用户是否拥有某徽章
*/
@Select("SELECT COUNT(*) FROM user_badge WHERE user_id = #{userId} AND badge_code = #{badgeCode}")
int countByUserAndBadge(@Param("userId") Long userId, @Param("badgeCode") String badgeCode);
}

View File

@ -0,0 +1,42 @@
package com.designhub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.designhub.entity.UserTitle;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 用户称号Mapper
*/
@Mapper
public interface UserTitleMapper extends BaseMapper<UserTitle> {
/**
* 获取用户的所有称号
*/
@Select("SELECT * FROM user_title WHERE user_id = #{userId} ORDER BY is_active DESC, create_time DESC")
List<UserTitle> selectByUserId(@Param("userId") Long userId);
/**
* 获取用户激活的称号
*/
@Select("SELECT * FROM user_title WHERE user_id = #{userId} AND is_active = 1 LIMIT 1")
UserTitle selectActiveByUserId(@Param("userId") Long userId);
/**
* 检查用户是否拥有某称号
*/
@Select("SELECT COUNT(*) FROM user_title WHERE user_id = #{userId} AND title_code = #{titleCode}")
int countByUserAndTitle(@Param("userId") Long userId, @Param("titleCode") String titleCode);
/**
* 取消用户所有称号的激活状态
*/
@Update("UPDATE user_title SET is_active = 0 WHERE user_id = #{userId}")
int deactivateAllByUserId(@Param("userId") Long userId);
}

View File

@ -0,0 +1,248 @@
package com.designhub.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.designhub.entity.*;
import com.designhub.mapper.*;
import com.designhub.vo.RewardStatsVO;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.TemporalAdjusters;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 奖励记录服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RewardRecordService {
private final RewardRecordMapper rewardRecordMapper;
private final UserBadgeMapper userBadgeMapper;
private final UserTitleMapper userTitleMapper;
private final BadgeDefinitionMapper badgeDefinitionMapper;
private final TitleDefinitionMapper titleDefinitionMapper;
private final ObjectMapper objectMapper;
/**
* 记录奖励发放
*/
@Transactional(rollbackFor = Exception.class)
public RewardRecord createRecord(Long userId, Long ruleId, String ruleName,
String rewardType, Object rewardValue, Object triggerData) {
RewardRecord record = new RewardRecord();
record.setUserId(userId);
record.setRuleId(ruleId);
record.setRuleName(ruleName);
record.setRewardType(rewardType);
record.setCreateTime(LocalDateTime.now());
try {
if (rewardValue != null) {
record.setRewardValue(objectMapper.writeValueAsString(rewardValue));
}
if (triggerData != null) {
record.setTriggerData(objectMapper.writeValueAsString(triggerData));
}
} catch (Exception e) {
log.error("序列化奖励数据失败", e);
}
rewardRecordMapper.insert(record);
log.info("记录奖励发放: userId={}, ruleId={}, type={}", userId, ruleId, rewardType);
return record;
}
/**
* 分页查询奖励记录
*/
public IPage<RewardRecord> getRecordList(Integer page, Integer pageSize,
Long userId, Long ruleId, String rewardType,
LocalDateTime startTime, LocalDateTime endTime) {
Page<RewardRecord> pageParam = new Page<>(page, pageSize);
return rewardRecordMapper.selectPageWithUser(pageParam, userId, ruleId, rewardType, startTime, endTime);
}
/**
* 获取我的奖励记录
*/
public IPage<RewardRecord> getMyRecords(Long userId, Integer page, Integer pageSize) {
return getRecordList(page, pageSize, userId, null, null, null, null);
}
/**
* 检查用户某规则获得次数
*/
public int getUserRuleCount(Long userId, Long ruleId) {
return rewardRecordMapper.countByUserAndRule(userId, ruleId);
}
/**
* 获取奖励统计数据
*/
public RewardStatsVO getStats(String period) {
LocalDateTime startTime;
LocalDateTime endTime = LocalDateTime.now();
switch (period) {
case "today":
startTime = LocalDate.now().atStartOfDay();
break;
case "week":
startTime = LocalDate.now().with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)).atStartOfDay();
break;
case "month":
startTime = LocalDate.now().with(TemporalAdjusters.firstDayOfMonth()).atStartOfDay();
break;
default:
startTime = LocalDate.now().with(TemporalAdjusters.firstDayOfMonth()).atStartOfDay();
}
RewardStatsVO stats = new RewardStatsVO();
stats.setTotalCount(rewardRecordMapper.countByTimeRange(startTime, endTime));
stats.setTotalScore(rewardRecordMapper.sumScoreByTimeRange(startTime, endTime));
stats.setUserCount(rewardRecordMapper.countDistinctUsersByTimeRange(startTime, endTime));
stats.setRuleRanking(rewardRecordMapper.getRuleRanking(startTime, endTime, 10));
stats.setUserRanking(rewardRecordMapper.getUserRanking(startTime, endTime, 10));
stats.setDailyTrend(rewardRecordMapper.getDailyTrend(startTime, endTime));
return stats;
}
// ==================== 徽章相关 ====================
/**
* 授予用户徽章
*/
@Transactional(rollbackFor = Exception.class)
public UserBadge grantBadge(Long userId, String badgeCode, String sourceType, Long sourceId) {
// 检查是否已拥有
if (userBadgeMapper.countByUserAndBadge(userId, badgeCode) > 0) {
log.info("用户已拥有该徽章: userId={}, badgeCode={}", userId, badgeCode);
return null;
}
// 获取徽章定义
BadgeDefinition badge = badgeDefinitionMapper.selectByCode(badgeCode);
if (badge == null) {
log.warn("徽章不存在: badgeCode={}", badgeCode);
return null;
}
UserBadge userBadge = new UserBadge();
userBadge.setUserId(userId);
userBadge.setBadgeCode(badgeCode);
userBadge.setBadgeName(badge.getBadgeName());
userBadge.setBadgeIcon(badge.getBadgeIcon());
userBadge.setBadgeColor(badge.getBadgeColor());
userBadge.setDescription(badge.getDescription());
userBadge.setSourceType(sourceType);
userBadge.setSourceId(sourceId);
userBadge.setCreateTime(LocalDateTime.now());
userBadgeMapper.insert(userBadge);
log.info("授予用户徽章: userId={}, badgeCode={}", userId, badgeCode);
return userBadge;
}
/**
* 获取用户徽章列表
*/
public List<UserBadge> getUserBadges(Long userId) {
return userBadgeMapper.selectByUserId(userId);
}
/**
* 获取所有可用徽章
*/
public List<BadgeDefinition> getAllBadges() {
return badgeDefinitionMapper.selectAllEnabled();
}
// ==================== 称号相关 ====================
/**
* 授予用户称号
*/
@Transactional(rollbackFor = Exception.class)
public UserTitle grantTitle(Long userId, String titleCode, String sourceType, Long sourceId, LocalDateTime expireTime) {
// 检查是否已拥有
if (userTitleMapper.countByUserAndTitle(userId, titleCode) > 0) {
log.info("用户已拥有该称号: userId={}, titleCode={}", userId, titleCode);
return null;
}
// 获取称号定义
TitleDefinition title = titleDefinitionMapper.selectByCode(titleCode);
if (title == null) {
log.warn("称号不存在: titleCode={}", titleCode);
return null;
}
UserTitle userTitle = new UserTitle();
userTitle.setUserId(userId);
userTitle.setTitleCode(titleCode);
userTitle.setTitleName(title.getTitleName());
userTitle.setTitleColor(title.getTitleColor());
userTitle.setDescription(title.getDescription());
userTitle.setIsActive(0);
userTitle.setExpireTime(expireTime);
userTitle.setSourceType(sourceType);
userTitle.setSourceId(sourceId);
userTitle.setCreateTime(LocalDateTime.now());
userTitleMapper.insert(userTitle);
log.info("授予用户称号: userId={}, titleCode={}", userId, titleCode);
return userTitle;
}
/**
* 获取用户称号列表
*/
public List<UserTitle> getUserTitles(Long userId) {
return userTitleMapper.selectByUserId(userId);
}
/**
* 获取用户当前激活的称号
*/
public UserTitle getActiveTitle(Long userId) {
return userTitleMapper.selectActiveByUserId(userId);
}
/**
* 设置激活称号
*/
@Transactional(rollbackFor = Exception.class)
public void setActiveTitle(Long userId, Long titleId) {
// 先取消所有激活
userTitleMapper.deactivateAllByUserId(userId);
// 激活指定称号
if (titleId != null && titleId > 0) {
UserTitle title = userTitleMapper.selectById(titleId);
if (title != null && title.getUserId().equals(userId)) {
title.setIsActive(1);
userTitleMapper.updateById(title);
}
}
}
/**
* 获取所有可用称号
*/
public List<TitleDefinition> getAllTitles() {
return titleDefinitionMapper.selectAllEnabled();
}
}

View File

@ -4,10 +4,12 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.designhub.common.ResultCode; import com.designhub.common.ResultCode;
import com.designhub.entity.RewardRecord;
import com.designhub.entity.RewardRule; import com.designhub.entity.RewardRule;
import com.designhub.entity.Task; import com.designhub.entity.Task;
import com.designhub.entity.User; import com.designhub.entity.User;
import com.designhub.exception.BusinessException; import com.designhub.exception.BusinessException;
import com.designhub.mapper.RewardRecordMapper;
import com.designhub.mapper.RewardRuleMapper; import com.designhub.mapper.RewardRuleMapper;
import com.designhub.mapper.TaskMapper; import com.designhub.mapper.TaskMapper;
import com.designhub.mapper.UserMapper; import com.designhub.mapper.UserMapper;
@ -34,6 +36,7 @@ import java.util.Map;
public class RewardRuleService { public class RewardRuleService {
private final RewardRuleMapper rewardRuleMapper; private final RewardRuleMapper rewardRuleMapper;
private final RewardRecordMapper rewardRecordMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final TaskMapper taskMapper; private final TaskMapper taskMapper;
private final NotificationService notificationService; private final NotificationService notificationService;
@ -240,11 +243,43 @@ public class RewardRuleService {
*/ */
@Transactional @Transactional
public void applyReward(RewardRule rule, User user) { public void applyReward(RewardRule rule, User user) {
applyReward(rule, user, null);
}
/**
* 应用奖励(带触发数据)
*/
@Transactional
public void applyReward(RewardRule rule, User user, Object triggerData) {
if (rule.getRewardScore() != null && rule.getRewardScore() > 0) { if (rule.getRewardScore() != null && rule.getRewardScore() > 0) {
int currentScore = user.getScore() != null ? user.getScore() : 0; int currentScore = user.getScore() != null ? user.getScore() : 0;
user.setScore(currentScore + rule.getRewardScore()); user.setScore(currentScore + rule.getRewardScore());
userMapper.updateById(user); userMapper.updateById(user);
// 记录奖励发放
try {
Map<String, Object> rewardValue = new java.util.HashMap<>();
rewardValue.put("score", rule.getRewardScore());
RewardRecord record = new RewardRecord();
record.setUserId(user.getId());
record.setRuleId(rule.getId());
record.setRuleName(rule.getName());
record.setRewardType("score");
record.setRewardValue(objectMapper.writeValueAsString(rewardValue));
if (triggerData != null) {
record.setTriggerData(objectMapper.writeValueAsString(triggerData));
}
record.setCreateTime(LocalDateTime.now());
rewardRecordMapper.insert(record);
} catch (Exception e) {
log.error("记录奖励发放失败", e);
}
// 更新规则触发次数
rule.setTriggerCount(rule.getTriggerCount() != null ? rule.getTriggerCount() + 1 : 1);
rewardRuleMapper.updateById(rule);
// 发送通知 // 发送通知
notificationService.sendNotification( notificationService.sendNotification(
user.getId(), user.getId(),

View File

@ -0,0 +1,52 @@
package com.designhub.vo;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 奖励统计VO
*/
@Data
public class RewardStatsVO {
/**
* 奖励发放总次数
*/
private Integer totalCount;
/**
* 发放总积分
*/
private Integer totalScore;
/**
* 获奖用户数
*/
private Integer userCount;
/**
* 规则命中率(已触发规则数/总规则数)
*/
private Double hitRate;
/**
* 规则触发排行
* [{ruleId, ruleName, triggerCount}, ...]
*/
private List<Map<String, Object>> ruleRanking;
/**
* 用户获奖排行
* [{userId, userName, userAvatar, totalScore}, ...]
*/
private List<Map<String, Object>> userRanking;
/**
* 每日趋势
* [{date, count, totalScore}, ...]
*/
private List<Map<String, Object>> dailyTrend;
}

View File

@ -0,0 +1,116 @@
-- ========================================
-- 奖励系统升级脚本
-- 版本:v2.0
-- 日期:2026-01-04
-- ========================================
-- 1. 新增奖励发放记录表
CREATE TABLE IF NOT EXISTS reward_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
rule_id BIGINT COMMENT '规则ID',
rule_name VARCHAR(100) COMMENT '规则名称',
reward_type VARCHAR(50) NOT NULL COMMENT '奖励类型:score/exp/badge/title',
reward_value TEXT COMMENT '奖励内容JSON',
trigger_data TEXT COMMENT '触发时的数据快照',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_rule_id (rule_id),
INDEX idx_create_time (create_time),
INDEX idx_reward_type (reward_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖励发放记录';
-- 2. 扩展奖励规则表
ALTER TABLE reward_rule ADD COLUMN IF NOT EXISTS reward_exp INT DEFAULT 0 COMMENT '奖励经验值';
ALTER TABLE reward_rule ADD COLUMN IF NOT EXISTS reward_title VARCHAR(50) COMMENT '奖励称号';
ALTER TABLE reward_rule ADD COLUMN IF NOT EXISTS reward_privilege TEXT COMMENT '奖励特权JSON';
ALTER TABLE reward_rule ADD COLUMN IF NOT EXISTS max_times_per_user INT DEFAULT 0 COMMENT '每用户最大获得次数(0=无限)';
ALTER TABLE reward_rule ADD COLUMN IF NOT EXISTS total_budget INT DEFAULT 0 COMMENT '总预算积分(0=无限)';
ALTER TABLE reward_rule ADD COLUMN IF NOT EXISTS used_budget INT DEFAULT 0 COMMENT '已使用预算';
ALTER TABLE reward_rule ADD COLUMN IF NOT EXISTS trigger_count INT DEFAULT 0 COMMENT '触发次数';
ALTER TABLE reward_rule ADD COLUMN IF NOT EXISTS start_time DATETIME COMMENT '生效开始时间';
ALTER TABLE reward_rule ADD COLUMN IF NOT EXISTS end_time DATETIME COMMENT '生效结束时间';
-- 3. 用户徽章表
CREATE TABLE IF NOT EXISTS user_badge (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
badge_code VARCHAR(50) NOT NULL COMMENT '徽章代码',
badge_name VARCHAR(100) NOT NULL COMMENT '徽章名称',
badge_icon VARCHAR(255) COMMENT '徽章图标URL',
badge_color VARCHAR(20) DEFAULT '#31f08b' COMMENT '徽章颜色',
description VARCHAR(255) COMMENT '徽章描述',
source_type VARCHAR(50) COMMENT '来源类型:rule/manual/achievement',
source_id BIGINT COMMENT '来源ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_badge (user_id, badge_code),
INDEX idx_user_id (user_id),
INDEX idx_badge_code (badge_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户徽章';
-- 4. 用户称号表
CREATE TABLE IF NOT EXISTS user_title (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
title_code VARCHAR(50) NOT NULL COMMENT '称号代码',
title_name VARCHAR(100) NOT NULL COMMENT '称号名称',
title_color VARCHAR(20) DEFAULT '#31f08b' COMMENT '称号颜色',
description VARCHAR(255) COMMENT '称号描述',
is_active INT DEFAULT 0 COMMENT '是否激活显示:0-否 1-是',
expire_time DATETIME COMMENT '过期时间(NULL=永久)',
source_type VARCHAR(50) COMMENT '来源类型:rule/manual/achievement',
source_id BIGINT COMMENT '来源ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_title (user_id, title_code),
INDEX idx_user_id (user_id),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户称号';
-- 5. 徽章定义表(系统预设徽章)
CREATE TABLE IF NOT EXISTS badge_definition (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
badge_code VARCHAR(50) NOT NULL UNIQUE COMMENT '徽章代码',
badge_name VARCHAR(100) NOT NULL COMMENT '徽章名称',
badge_icon VARCHAR(255) COMMENT '徽章图标URL',
badge_color VARCHAR(20) DEFAULT '#31f08b' COMMENT '徽章颜色',
description VARCHAR(255) COMMENT '徽章描述',
category VARCHAR(50) DEFAULT 'general' COMMENT '分类:general/task/rating/special',
sort_order INT DEFAULT 0 COMMENT '排序',
enabled INT DEFAULT 1 COMMENT '是否启用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_category (category),
INDEX idx_enabled (enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='徽章定义';
-- 6. 称号定义表(系统预设称号)
CREATE TABLE IF NOT EXISTS title_definition (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title_code VARCHAR(50) NOT NULL UNIQUE COMMENT '称号代码',
title_name VARCHAR(100) NOT NULL COMMENT '称号名称',
title_color VARCHAR(20) DEFAULT '#31f08b' COMMENT '称号颜色',
description VARCHAR(255) COMMENT '称号描述',
category VARCHAR(50) DEFAULT 'general' COMMENT '分类',
sort_order INT DEFAULT 0 COMMENT '排序',
enabled INT DEFAULT 1 COMMENT '是否启用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_category (category),
INDEX idx_enabled (enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='称号定义';
-- 7. 初始化一些默认徽章
INSERT IGNORE INTO badge_definition (badge_code, badge_name, badge_icon, badge_color, description, category, sort_order) VALUES
('efficiency_star', '效率之星', NULL, '#31f08b', '每周完成20个任务获得', 'task', 1),
('five_star_master', '好评大师', NULL, '#FFD700', '累计获得50个五星好评', 'rating', 2),
('task_master', '任务达人', NULL, '#1890ff', '累计完成100个任务', 'task', 3),
('speed_demon', '极速先锋', NULL, '#ff4d4f', '连续7天准时完成所有任务', 'task', 4),
('newcomer', '新人之星', NULL, '#52c41a', '首次完成任务获得', 'special', 5),
('perfect_week', '完美周', NULL, '#722ed1', '一周内所有任务都获得五星好评', 'rating', 6);
-- 8. 初始化一些默认称号
INSERT IGNORE INTO title_definition (title_code, title_name, title_color, description, category, sort_order) VALUES
('design_master', '设计大师', '#FFD700', '设计领域的佼佼者', 'general', 1),
('efficiency_king', '效率之王', '#31f08b', '高效完成任务的典范', 'general', 2),
('quality_guardian', '品质卫士', '#1890ff', '始终保持高品质输出', 'general', 3),
('team_star', '团队之星', '#ff4d4f', '团队贡献突出', 'general', 4),
('rising_star', '新星', '#52c41a', '快速成长的新人', 'general', 5);

View File

@ -21,11 +21,96 @@ export interface RewardRule {
cronExpression?: string cronExpression?: string
lastExecuteTime?: string lastExecuteTime?: string
priority: number priority: number
triggerCount?: number
totalBudget?: number
usedBudget?: number
maxTimesPerUser?: number
startTime?: string
endTime?: string
creatorId: number creatorId: number
createTime: string createTime: string
updateTime: string updateTime: string
} }
// 奖励发放记录
export interface RewardRecord {
id: number
userId: number
ruleId: number
ruleName: string
rewardType: string
rewardValue: string
triggerData: string
createTime: string
userName?: string
userAvatar?: string
}
// 用户徽章
export interface UserBadge {
id: number
userId: number
badgeCode: string
badgeName: string
badgeIcon: string
badgeColor: string
description: string
sourceType: string
sourceId: number
createTime: string
}
// 徽章定义
export interface BadgeDefinition {
id: number
badgeCode: string
badgeName: string
badgeIcon: string
badgeColor: string
description: string
category: string
sortOrder: number
enabled: number
}
// 用户称号
export interface UserTitle {
id: number
userId: number
titleCode: string
titleName: string
titleColor: string
description: string
isActive: number
expireTime: string
sourceType: string
sourceId: number
createTime: string
}
// 称号定义
export interface TitleDefinition {
id: number
titleCode: string
titleName: string
titleColor: string
description: string
category: string
sortOrder: number
enabled: number
}
// 奖励统计
export interface RewardStats {
totalCount: number
totalScore: number
userCount: number
hitRate: number
ruleRanking: Array<{ ruleId: number; ruleName: string; triggerCount: number }>
userRanking: Array<{ userId: number; userName: string; userAvatar: string; totalScore: number }>
dailyTrend: Array<{ date: string; count: number; totalScore: number }>
}
export interface RewardRuleCreateRequest { export interface RewardRuleCreateRequest {
name: string name: string
description?: string description?: string
@ -116,6 +201,115 @@ export function initDefaultRewardRules(): Promise<void> {
.then(res => res.data) .then(res => res.data)
} }
// ==================== 奖励统计 API ====================
/**
* 获取奖励统计数据
* @param period today/week/month
*/
export function getRewardStats(period: string = 'month'): Promise<RewardStats> {
return request.get<Result<RewardStats>>('/reward-rules/stats', { params: { period } })
.then(res => res.data)
}
// ==================== 奖励记录 API ====================
/**
* 获取奖励发放记录
*/
export function getRewardRecords(params: {
page?: number
pageSize?: number
userId?: number
ruleId?: number
rewardType?: string
startTime?: string
endTime?: string
}): Promise<PageResult<RewardRecord>> {
return request.get<Result<PageResult<RewardRecord>>>('/reward-rules/records', { params })
.then(res => res.data)
}
/**
* 获取我的奖励记录
*/
export function getMyRewardRecords(params: {
page?: number
pageSize?: number
}): Promise<PageResult<RewardRecord>> {
return request.get<Result<PageResult<RewardRecord>>>('/reward-rules/records/my', { params })
.then(res => res.data)
}
// ==================== 徽章 API ====================
/**
* 获取所有可用徽章
*/
export function getAllBadges(): Promise<BadgeDefinition[]> {
return request.get<Result<BadgeDefinition[]>>('/reward-rules/badges')
.then(res => res.data)
}
/**
* 获取用户徽章
*/
export function getUserBadges(userId: number): Promise<UserBadge[]> {
return request.get<Result<UserBadge[]>>(`/reward-rules/users/${userId}/badges`)
.then(res => res.data)
}
/**
* 获取我的徽章
*/
export function getMyBadges(): Promise<UserBadge[]> {
return request.get<Result<UserBadge[]>>('/reward-rules/badges/my')
.then(res => res.data)
}
// ==================== 称号 API ====================
/**
* 获取所有可用称号
*/
export function getAllTitles(): Promise<TitleDefinition[]> {
return request.get<Result<TitleDefinition[]>>('/reward-rules/titles')
.then(res => res.data)
}
/**
* 获取用户称号
*/
export function getUserTitles(userId: number): Promise<UserTitle[]> {
return request.get<Result<UserTitle[]>>(`/reward-rules/users/${userId}/titles`)
.then(res => res.data)
}
/**
* 获取我的称号
*/
export function getMyTitles(): Promise<UserTitle[]> {
return request.get<Result<UserTitle[]>>('/reward-rules/titles/my')
.then(res => res.data)
}
/**
* 设置激活称号
*/
export function setActiveTitle(titleId?: number): Promise<void> {
return request.put<Result<void>>('/reward-rules/titles/active', null, {
params: { titleId }
}).then(res => res.data)
}
/**
* 获取我的激活称号
*/
export function getMyActiveTitle(): Promise<UserTitle | null> {
return request.get<Result<UserTitle | null>>('/reward-rules/titles/active')
.then(res => res.data)
}
// 规则类型选项 // 规则类型选项
export const RULE_TYPE_OPTIONS = [ export const RULE_TYPE_OPTIONS = [
{ value: 'daily', label: '每日奖励' }, { value: 'daily', label: '每日奖励' },

View File

@ -7,9 +7,9 @@
<div class="header-left"> <div class="header-left">
<h1 class="page-title"> <h1 class="page-title">
<Icon name="settings" size="xl" color="#31f08b" style="margin-right: 12px;" /> <Icon name="settings" size="xl" color="#31f08b" style="margin-right: 12px;" />
自动奖励规则 奖励规则管理
</h1> </h1>
<p class="page-desc">配置系统自动奖励规则,激励团队成长</p> <p class="page-desc">配置自动奖励规则,查看奖励发放统计</p>
</div> </div>
<div class="header-right"> <div class="header-right">
<a-button type="primary" @click="showCreateModal"> <a-button type="primary" @click="showCreateModal">
@ -19,88 +19,223 @@
</div> </div>
</div> </div>
<!-- 筛选 --> <!-- Tab切换 -->
<div class="filter-bar"> <a-tabs v-model:activeKey="activeTab" class="reward-tabs">
<a-select v-model:value="filterType" placeholder="规则类型" style="width: 150px" allowClear @change="loadData"> <!-- 规则管理 -->
<a-select-option v-for="opt in RULE_TYPE_OPTIONS" :key="opt.value" :value="opt.value"> <a-tab-pane key="rules" tab="规则管理">
{{ opt.label }} <!-- 筛选 -->
</a-select-option> <div class="filter-bar">
</a-select> <a-space>
</div> <a-select v-model:value="filterType" placeholder="规则类型" style="width: 150px" allowClear @change="loadData">
<a-select-option v-for="opt in RULE_TYPE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
<a-select v-model:value="filterStatus" placeholder="状态" style="width: 120px" allowClear @change="loadData">
<a-select-option value="1">已启用</a-select-option>
<a-select-option value="0">已禁用</a-select-option>
</a-select>
</a-space>
</div>
<!-- 规则列表 --> <!-- 规则列表 -->
<div class="rules-list"> <div class="rules-list">
<div v-if="loading" class="loading-container"> <div v-if="loading" class="loading-container">
<a-spin size="large" /> <a-spin size="large" />
</div>
<div v-else-if="rules.length === 0" class="empty-state">
<Icon name="inbox" size="xl" color="#6B6B6B" />
<p>暂无奖励规则</p>
<a-button type="primary" @click="showCreateModal">创建规则</a-button>
</div>
<div v-else>
<div v-for="rule in rules" :key="rule.id" class="rule-card">
<div class="rule-header">
<div class="rule-info">
<h3 class="rule-name">{{ rule.name }}</h3>
<a-tag :color="getRuleTypeColor(rule.ruleType)">
{{ getRuleTypeName(rule.ruleType) }}
</a-tag>
</div>
<div class="rule-status">
<a-switch
:checked="rule.enabled === 1"
@change="(checked: boolean) => toggleRule(rule, checked)"
checked-children="启用"
un-checked-children="禁用"
/>
</div>
</div> </div>
<div v-else-if="rules.length === 0" class="empty-state">
<p class="rule-desc">{{ rule.description }}</p> <Icon name="inbox" size="xl" color="#6B6B6B" />
<p>暂无奖励规则</p>
<div class="rule-details"> <a-button type="primary" @click="showCreateModal">创建规则</a-button>
<div class="detail-item">
<span class="detail-label">触发条件</span>
<span class="detail-value">{{ formatCondition(rule.triggerCondition) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">奖励</span>
<span class="detail-value reward">+{{ rule.rewardScore }} 积分</span>
</div>
<div class="detail-item">
<span class="detail-label">适用范围</span>
<span class="detail-value">{{ getScopeName(rule.scope) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">优先级</span>
<span class="detail-value">{{ rule.priority }}</span>
</div>
</div> </div>
<div v-else>
<div v-for="rule in rules" :key="rule.id" class="rule-card">
<div class="rule-header">
<div class="rule-info">
<h3 class="rule-name">{{ rule.name }}</h3>
<a-tag :color="getRuleTypeColor(rule.ruleType)">
{{ getRuleTypeName(rule.ruleType) }}
</a-tag>
</div>
<div class="rule-status">
<a-switch
:checked="rule.enabled === 1"
@change="(checked: boolean) => toggleRule(rule, checked)"
checked-children="启用"
un-checked-children="禁用"
/>
</div>
</div>
<p class="rule-desc">{{ rule.description }}</p>
<div class="rule-details">
<div class="detail-item">
<span class="detail-label">触发条件</span>
<span class="detail-value">{{ formatCondition(rule.triggerCondition) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">奖励</span>
<span class="detail-value reward">+{{ rule.rewardScore }} 积分</span>
</div>
<div class="detail-item">
<span class="detail-label">适用范围</span>
<span class="detail-value">{{ getScopeName(rule.scope) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">触发次数</span>
<span class="detail-value">{{ rule.triggerCount || 0 }} 次</span>
</div>
</div>
<div class="rule-footer"> <div class="rule-footer">
<span class="rule-time">创建于 {{ formatDate(rule.createTime) }}</span> <span class="rule-time">创建于 {{ formatDate(rule.createTime) }}</span>
<div class="rule-actions"> <div class="rule-actions">
<a-button size="small" @click="editRule(rule)">编辑</a-button> <a-button size="small" @click="editRule(rule)">编辑</a-button>
<a-popconfirm title="确定删除此规则吗?" @confirm="handleDelete(rule.id)"> <a-popconfirm title="确定删除此规则吗?" @confirm="handleDelete(rule.id)">
<a-button size="small" danger>删除</a-button> <a-button size="small" danger>删除</a-button>
</a-popconfirm> </a-popconfirm>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- 分页 --> <!-- 分页 -->
<div class="pagination-container" v-if="total > 0"> <div class="pagination-container" v-if="total > 0">
<a-pagination <a-pagination
v-model:current="currentPage" v-model:current="currentPage"
:total="total" :total="total"
:page-size="pageSize" :page-size="pageSize"
show-size-changer show-size-changer
@change="handlePageChange" @change="handlePageChange"
/> />
</div> </div>
</a-tab-pane>
<!-- 奖励统计 -->
<a-tab-pane key="stats" tab="奖励统计">
<div class="stats-section">
<!-- 时间筛选 -->
<div class="stats-filter">
<a-radio-group v-model:value="statsPeriod" button-style="solid" @change="loadStats">
<a-radio-button value="today">今日</a-radio-button>
<a-radio-button value="week">本周</a-radio-button>
<a-radio-button value="month">本月</a-radio-button>
</a-radio-group>
</div>
<!-- 统计卡片 -->
<div class="stats-cards">
<div class="stat-card">
<div class="stat-value">{{ stats.totalCount || 0 }}</div>
<div class="stat-label">奖励次数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.totalScore || 0 }}</div>
<div class="stat-label">发放积分</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.userCount || 0 }}</div>
<div class="stat-label">获奖人数</div>
</div>
</div>
<!-- 排行榜 -->
<div class="ranking-section">
<a-row :gutter="24">
<a-col :span="12">
<div class="ranking-card">
<h3 class="ranking-title">规则触发排行</h3>
<div class="ranking-list">
<div v-for="(item, index) in (stats.ruleRanking || [])" :key="index" class="ranking-item">
<span class="rank" :class="{ 'top3': index < 3 }">{{ index + 1 }}</span>
<span class="name">{{ item.rule_name || item.ruleName }}</span>
<span class="count">{{ item.trigger_count || item.triggerCount }} 次</span>
</div>
<div v-if="!stats.ruleRanking?.length" class="empty-ranking">暂无数据</div>
</div>
</div>
</a-col>
<a-col :span="12">
<div class="ranking-card">
<h3 class="ranking-title">用户获奖排行</h3>
<div class="ranking-list">
<div v-for="(item, index) in (stats.userRanking || [])" :key="index" class="ranking-item">
<span class="rank" :class="{ 'top3': index < 3 }">{{ index + 1 }}</span>
<span class="name">{{ item.user_name || item.userName }}</span>
<span class="score">+{{ item.total_score || item.totalScore }} 分</span>
</div>
<div v-if="!stats.userRanking?.length" class="empty-ranking">暂无数据</div>
</div>
</div>
</a-col>
</a-row>
</div>
</div>
</a-tab-pane>
<!-- 发放记录 -->
<a-tab-pane key="records" tab="发放记录">
<div class="records-section">
<a-table
:columns="recordColumns"
:data-source="records"
:loading="recordsLoading"
:pagination="{ current: recordPage, pageSize: 20, total: recordTotal, onChange: loadRecords }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'rewardValue'">
<span class="reward-value">{{ parseRewardValue(record.rewardValue) }}</span>
</template>
<template v-if="column.key === 'createTime'">
{{ formatDate(record.createTime) }}
</template>
</template>
</a-table>
</div>
</a-tab-pane>
<!-- 徽章管理 -->
<a-tab-pane key="badges" tab="徽章管理">
<div class="badges-section">
<div class="badges-grid">
<div v-for="badge in badges" :key="badge.id" class="badge-card">
<div class="badge-icon" :style="{ backgroundColor: badge.badgeColor }">
{{ badge.badgeName.charAt(0) }}
</div>
<div class="badge-info">
<h4>{{ badge.badgeName }}</h4>
<p>{{ badge.description }}</p>
<a-tag>{{ badge.category }}</a-tag>
</div>
</div>
<div v-if="!badges.length" class="empty-badges">
暂无徽章定义
</div>
</div>
</div>
</a-tab-pane>
<!-- 称号管理 -->
<a-tab-pane key="titles" tab="称号管理">
<div class="titles-section">
<div class="titles-grid">
<div v-for="title in titles" :key="title.id" class="title-card">
<div class="title-name" :style="{ color: title.titleColor }">
{{ title.titleName }}
</div>
<p class="title-desc">{{ title.description }}</p>
<a-tag>{{ title.category }}</a-tag>
</div>
<div v-if="!titles.length" class="empty-titles">
暂无称号定义
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
</div> </div>
</div> </div>
@ -184,7 +319,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, watch } from 'vue'
import DefaultLayout from '@/layouts/DefaultLayout.vue' import DefaultLayout from '@/layouts/DefaultLayout.vue'
import Icon from '@/components/Icon.vue' import Icon from '@/components/Icon.vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
@ -196,24 +331,39 @@ import {
deleteRewardRule, deleteRewardRule,
enableRewardRule, enableRewardRule,
disableRewardRule, disableRewardRule,
getRewardStats,
getRewardRecords,
getAllBadges,
getAllTitles,
RULE_TYPE_OPTIONS, RULE_TYPE_OPTIONS,
TRIGGER_TYPE_OPTIONS, TRIGGER_TYPE_OPTIONS,
OPERATOR_OPTIONS, OPERATOR_OPTIONS,
type RewardRule type RewardRule,
type RewardStats,
type RewardRecord,
type BadgeDefinition,
type TitleDefinition
} from '@/api/rewardRule' } from '@/api/rewardRule'
// Tab状态
const activeTab = ref('rules')
// 规则列表状态
const loading = ref(false) const loading = ref(false)
const rules = ref<RewardRule[]>([]) const rules = ref<RewardRule[]>([])
const total = ref(0) const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const filterType = ref<string | undefined>(undefined) const filterType = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
// 弹窗状态
const modalVisible = ref(false) const modalVisible = ref(false)
const isEdit = ref(false) const isEdit = ref(false)
const submitting = ref(false) const submitting = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
// 规则表单
const ruleForm = ref({ const ruleForm = ref({
name: '', name: '',
description: '', description: '',
@ -226,6 +376,29 @@ const ruleForm = ref({
priority: 100 priority: 100
}) })
// 统计数据
const statsPeriod = ref('month')
const stats = ref<Partial<RewardStats>>({})
// 发放记录
const recordsLoading = ref(false)
const records = ref<RewardRecord[]>([])
const recordPage = ref(1)
const recordTotal = ref(0)
const recordColumns = [
{ title: '用户', dataIndex: 'userName', key: 'userName' },
{ title: '规则', dataIndex: 'ruleName', key: 'ruleName' },
{ title: '奖励类型', dataIndex: 'rewardType', key: 'rewardType' },
{ title: '奖励内容', dataIndex: 'rewardValue', key: 'rewardValue' },
{ title: '发放时间', dataIndex: 'createTime', key: 'createTime' },
]
// 徽章和称号
const badges = ref<BadgeDefinition[]>([])
const titles = ref<TitleDefinition[]>([])
// 加载规则列表
const loadData = async () => { const loadData = async () => {
loading.value = true loading.value = true
try { try {
@ -244,6 +417,56 @@ const loadData = async () => {
} }
} }
// 加载统计数据
const loadStats = async () => {
try {
stats.value = await getRewardStats(statsPeriod.value)
} catch (error) {
console.error('加载统计失败:', error)
}
}
// 加载发放记录
const loadRecords = async (page: number = 1) => {
recordsLoading.value = true
recordPage.value = page
try {
const result = await getRewardRecords({ page, pageSize: 20 })
records.value = result.records || []
recordTotal.value = result.total || 0
} catch (error) {
console.error('加载记录失败:', error)
} finally {
recordsLoading.value = false
}
}
// 加载徽章
const loadBadges = async () => {
try {
badges.value = await getAllBadges()
} catch (error) {
console.error('加载徽章失败:', error)
}
}
// 加载称号
const loadTitles = async () => {
try {
titles.value = await getAllTitles()
} catch (error) {
console.error('加载称号失败:', error)
}
}
// 监听Tab切换
watch(activeTab, (newTab) => {
if (newTab === 'stats') loadStats()
if (newTab === 'records') loadRecords()
if (newTab === 'badges') loadBadges()
if (newTab === 'titles') loadTitles()
})
const showCreateModal = () => { const showCreateModal = () => {
isEdit.value = false isEdit.value = false
editingId.value = null editingId.value = null
@ -382,7 +605,11 @@ const getScopeName = (scope: string) => {
const formatCondition = (condition: any) => { const formatCondition = (condition: any) => {
if (typeof condition === 'string') { if (typeof condition === 'string') {
condition = JSON.parse(condition) try {
condition = JSON.parse(condition)
} catch {
return condition
}
} }
const typeOpt = TRIGGER_TYPE_OPTIONS.find(o => o.value === condition.type) const typeOpt = TRIGGER_TYPE_OPTIONS.find(o => o.value === condition.type)
const opOpt = OPERATOR_OPTIONS.find(o => o.value === condition.operator) const opOpt = OPERATOR_OPTIONS.find(o => o.value === condition.operator)
@ -391,19 +618,32 @@ const formatCondition = (condition: any) => {
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm') const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm')
const parseRewardValue = (value: string) => {
try {
const obj = JSON.parse(value)
if (obj.score) return `+${obj.score} 积分`
return value
} catch {
return value
}
}
onMounted(() => { onMounted(() => {
loadData() loadData()
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/styles/variables.scss";
.reward-rules-page { .reward-rules-page {
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 24px;
background: $bg-primary;
} }
.container { .container {
max-width: 1000px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@ -417,14 +657,14 @@ onMounted(() => {
.page-title { .page-title {
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 600;
color: #ECECEC; color: $text-primary;
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 8px;
} }
.page-desc { .page-desc {
color: #AFAFAF; color: $text-secondary;
font-size: 14px; font-size: 14px;
} }
@ -439,10 +679,15 @@ onMounted(() => {
} }
.rule-card { .rule-card {
background: #2A2A2A; background: $bg-card;
border: 1px solid #3A3A3A; border: 1px solid $border-primary;
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
transition: all 0.3s;
&:hover {
border-color: $brand-primary;
}
} }
.rule-header { .rule-header {
@ -461,11 +706,11 @@ onMounted(() => {
.rule-name { .rule-name {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #ECECEC; color: $text-primary;
} }
.rule-desc { .rule-desc {
color: #AFAFAF; color: $text-secondary;
font-size: 14px; font-size: 14px;
margin-bottom: 16px; margin-bottom: 16px;
} }
@ -476,7 +721,7 @@ onMounted(() => {
gap: 16px; gap: 16px;
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px; padding: 16px;
background: #333; background: $bg-secondary;
border-radius: 8px; border-radius: 8px;
} }
@ -488,15 +733,15 @@ onMounted(() => {
.detail-label { .detail-label {
font-size: 12px; font-size: 12px;
color: #6B6B6B; color: $text-tertiary;
} }
.detail-value { .detail-value {
font-size: 14px; font-size: 14px;
color: #ECECEC; color: $text-primary;
&.reward { &.reward {
color: #31f08b; color: $brand-primary;
font-weight: 600; font-weight: 600;
} }
} }
@ -506,12 +751,12 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-top: 16px; padding-top: 16px;
border-top: 1px solid #3A3A3A; border-top: 1px solid $border-primary;
} }
.rule-time { .rule-time {
font-size: 12px; font-size: 12px;
color: #6B6B6B; color: $text-tertiary;
} }
.rule-actions { .rule-actions {
@ -528,7 +773,7 @@ onMounted(() => {
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 48px; padding: 48px;
color: #6B6B6B; color: $text-tertiary;
p { p {
margin: 16px 0; margin: 16px 0;
@ -540,4 +785,206 @@ onMounted(() => {
justify-content: center; justify-content: center;
padding: 48px; padding: 48px;
} }
// 统计样式
.stats-section {
.stats-filter {
margin-bottom: 24px;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 24px;
text-align: center;
.stat-value {
font-size: 36px;
font-weight: 700;
color: $brand-primary;
}
.stat-label {
font-size: 14px;
color: $text-secondary;
margin-top: 8px;
}
}
}
.ranking-section {
.ranking-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
}
.ranking-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
margin-bottom: 16px;
}
.ranking-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.ranking-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: $bg-secondary;
border-radius: 8px;
.rank {
width: 24px;
height: 24px;
border-radius: 50%;
background: $bg-tertiary;
color: $text-secondary;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
&.top3 {
background: $brand-primary;
color: #000;
}
}
.name {
flex: 1;
color: $text-primary;
}
.count, .score {
color: $brand-primary;
font-weight: 600;
}
}
.empty-ranking {
text-align: center;
color: $text-tertiary;
padding: 24px;
}
}
// 徽章样式
.badges-section {
.badges-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.badge-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
display: flex;
gap: 16px;
align-items: center;
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: 600;
}
.badge-info {
flex: 1;
h4 {
font-size: 16px;
font-weight: 600;
color: $text-primary;
margin-bottom: 4px;
}
p {
font-size: 12px;
color: $text-secondary;
margin-bottom: 8px;
}
}
}
.empty-badges {
text-align: center;
color: $text-tertiary;
padding: 48px;
grid-column: 1 / -1;
}
}
// 称号样式
.titles-section {
.titles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.title-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
text-align: center;
.title-name {
font-size: 20px;
font-weight: 700;
margin-bottom: 8px;
}
.title-desc {
font-size: 12px;
color: $text-secondary;
margin-bottom: 12px;
}
}
.empty-titles {
text-align: center;
color: $text-tertiary;
padding: 48px;
grid-column: 1 / -1;
}
}
// Tabs样式
.reward-tabs {
:deep(.ant-tabs-nav) {
margin-bottom: 24px;
}
}
.reward-value {
color: $brand-primary;
font-weight: 600;
}
</style> </style>

356
奖励规则优化方案.md Normal file
View File

@ -0,0 +1,356 @@
# 奖励规则功能优化方案
## 一、现状分析
### 1.1 当前功能
| 模块 | 功能 | 状态 |
|------|------|------|
| 规则管理 | 创建/编辑/删除/启用/禁用 | ✅ 已实现 |
| 规则类型 | 每日/每周/每月/任务/好评奖励 | ✅ 已实现 |
| 触发条件 | 任务数量/评分/五星数量 | ✅ 已实现 |
| 定时执行 | 每日/每周/每月定时检查 | ✅ 已实现 |
| 奖励发放 | 积分奖励+通知 | ✅ 已实现 |
### 1.2 存在的问题
1. **奖励类型单一**:目前只支持积分奖励,缺少徽章、称号等多样化奖励
2. **触发条件有限**:仅支持简单的数值比较,缺少复合条件、时间条件等
3. **缺乏奖励记录**:无法追溯历史奖励发放情况
4. **UI体验一般**:配置界面不够直观,缺乏可视化编辑
5. **无预览功能**:无法预估规则影响范围和预期奖励
6. **缺少数据统计**:管理员无法看到奖励发放的整体数据
---
## 二、优化目标
1. **丰富奖励类型**:支持积分、徽章、称号、经验值等多种奖励
2. **增强触发条件**:支持更灵活的条件组合
3. **完善奖励追溯**:记录每一次奖励发放
4. **提升用户体验**:可视化配置、实时预览
5. **数据分析支持**:奖励发放统计和效果分析
---
## 三、功能设计
### 3.1 奖励类型扩展
| 奖励类型 | 说明 | 示例 |
|----------|------|------|
| 积分 (score) | 通用积分奖励 | +50 积分 |
| 经验值 (exp) | 用户等级经验 | +100 经验 |
| 徽章 (badge) | 特殊成就标识 | "效率之星" 徽章 |
| 称号 (title) | 显示在用户名旁 | "设计大师" |
| 特权 (privilege) | 临时功能权限 | 7天优先接单权 |
| 实物兑换券 (coupon) | 可兑换实物 | 咖啡券 x1 |
### 3.2 触发条件扩展
#### 基础条件
| 条件类型 | 参数 | 说明 |
|----------|------|------|
| task_count | 任务数量 | 完成任务数 ≥ N |
| score_earned | 获得积分 | 累计获得积分 ≥ N |
| five_star_rate | 好评率 | 好评率 ≥ N% |
| on_time_rate | 准时率 | 准时完成率 ≥ N% |
| consecutive_days | 连续天数 | 连续 N 天完成任务 |
| first_task | 首单 | 首次完成任务 |
| login_days | 登录天数 | 本月登录天数 ≥ N |
#### 复合条件(AND/OR)
```json
{
"logic": "AND",
"conditions": [
{ "type": "task_count", "operator": ">=", "value": 10 },
{ "type": "five_star_rate", "operator": ">=", "value": 90 }
]
}
```
#### 时间范围条件
```json
{
"type": "task_count",
"operator": ">=",
"value": 5,
"timeRange": "today" // today/this_week/this_month/custom
}
```
### 3.3 新增功能模块
#### 3.3.1 奖励发放记录表 `reward_record`
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| user_id | bigint | 用户ID |
| rule_id | bigint | 规则ID |
| rule_name | varchar | 规则名称(冗余) |
| reward_type | varchar | 奖励类型 |
| reward_value | varchar | 奖励内容(JSON) |
| trigger_data | text | 触发时的数据快照 |
| create_time | datetime | 发放时间 |
#### 3.3.2 奖励统计面板
- **今日奖励**:今日发放奖励次数、总积分
- **本周奖励**:本周奖励趋势图
- **规则效果**:各规则触发次数排行
- **用户受益**:获奖用户排行榜
#### 3.3.3 用户奖励中心
- 我的奖励历史
- 我的徽章墙
- 我的称号
- 奖励日历(按日期查看获奖记录)
---
## 四、UI/UX 设计
### 4.1 规则配置页面重构
```
┌─────────────────────────────────────────────────────────────┐
│ 自动奖励规则管理 [+ 新建规则] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─ 筛选 ─────────────────────────────────────────────────┐ │
│ │ 类型: [全部 ▼] 状态: [全部 ▼] 搜索: [___________] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 规则卡片 ──────────────────────────────────────────────┐ │
│ │ 🏆 每日任务达人 [启用 ○] [编辑] │ │
│ │ ───────────────────────────────────────────────────── │ │
│ │ 📋 触发: 每天完成 ≥ 5 个任务 │ │
│ │ 🎁 奖励: +20 积分 │ │
│ │ 👥 范围: 全体用户 │ │
│ │ 📊 统计: 本周触发 127 次 | 共奖励 2,540 积分 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 规则卡片 ──────────────────────────────────────────────┐ │
│ │ ⭐ 五星好评奖励 [启用 ○] [编辑] │ │
│ │ ───────────────────────────────────────────────────── │ │
│ │ 📋 触发: 获得 5 星评价 │ │
│ │ 🎁 奖励: +10 积分 + "好评之星" 徽章 │ │
│ │ 👥 范围: 设计师 │ │
│ │ 📊 统计: 本周触发 89 次 | 共奖励 890 积分 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 可视化规则编辑器
```
┌─────────────────────────────────────────────────────────────┐
│ 新建奖励规则 [X 关闭] │
├─────────────────────────────────────────────────────────────┤
│ │
│ 基本信息 │
│ ├─ 规则名称: [每周效率之星_______________] │
│ ├─ 规则描述: [每周完成20个任务的设计师奖励] │
│ └─ 规则类型: ○每日 ●每周 ○每月 ○即时 ○好评 │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ 触发条件 [+ 添加条件] │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ [完成任务数 ▼] [≥ ▼] [20____] [本周内 ▼] [✕] │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ ├─ 逻辑: ○ 满足全部条件(AND) ○ 满足任一条件(OR) │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ 奖励设置 [+ 添加奖励] │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ [积分 ▼] 值: [100___] [✕] │ │
│ ├───────────────────────────────────────────────────────┤ │
│ │ [徽章 ▼] 选择: [效率之星 ▼] [✕] │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ 适用范围 │
│ ├─ ○ 全体用户 │
│ ├─ ● 指定角色: [☑设计师 ☐需求方 ☐部门负责人] │
│ └─ ○ 指定部门: [选择部门...] │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ 规则预览 │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 📋 当设计师在本周内完成任务数 ≥ 20 时 │ │
│ │ 🎁 自动发放: 100 积分 + "效率之星" 徽章 │ │
│ │ 👥 预计影响: 约 23 人可能获得此奖励 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ [取消] [保存规则] │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 4.3 奖励统计面板
```
┌─────────────────────────────────────────────────────────────┐
│ 奖励数据统计 [本月 ▼] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 1,234 │ │ 15,680 │ │ 89% │ │ 47 │ │
│ │ 奖励次数 │ │ 发放积分 │ │ 规则命中率│ │ 获奖人数 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌─ 奖励趋势 ───────────────────────────────────────────┐ │
│ │ ▂▃▄▅▆▇█▇▆▅▄▃▂▁▂▃▄▅▆▇█▇▆▅▄▃▂ │ │
│ │ 1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 规则触发排行 ─────────┐ ┌─ 获奖用户排行 ─────────┐ │
│ │ 1. 五星好评奖励 423次 │ │ 1. 张三 +580 积分 │ │
│ │ 2. 每日任务达人 312次 │ │ 2. 李四 +520 积分 │ │
│ │ 3. 每周效率之星 89次 │ │ 3. 王五 +490 积分 │ │
│ │ 4. 连续签到奖励 67次 │ │ 4. 赵六 +450 积分 │ │
│ │ 5. 首单奖励 45次 │ │ 5. 钱七 +420 积分 │ │
│ └───────────────────────┘ └───────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## 五、技术实现
### 5.1 数据库变更
```sql
-- 1. 新增奖励发放记录表
CREATE TABLE reward_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
rule_id BIGINT COMMENT '规则ID',
rule_name VARCHAR(100) COMMENT '规则名称',
reward_type VARCHAR(50) NOT NULL COMMENT '奖励类型',
reward_value TEXT COMMENT '奖励内容JSON',
trigger_data TEXT COMMENT '触发数据快照',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_rule_id (rule_id),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='奖励发放记录';
-- 2. 扩展奖励规则表
ALTER TABLE reward_rule ADD COLUMN reward_exp INT DEFAULT 0 COMMENT '奖励经验值';
ALTER TABLE reward_rule ADD COLUMN reward_title VARCHAR(50) COMMENT '奖励称号';
ALTER TABLE reward_rule ADD COLUMN reward_privilege TEXT COMMENT '奖励特权JSON';
ALTER TABLE reward_rule ADD COLUMN max_times_per_user INT DEFAULT 0 COMMENT '每用户最大获得次数(0=无限)';
ALTER TABLE reward_rule ADD COLUMN total_budget INT DEFAULT 0 COMMENT '总预算积分(0=无限)';
ALTER TABLE reward_rule ADD COLUMN used_budget INT DEFAULT 0 COMMENT '已使用预算';
ALTER TABLE reward_rule ADD COLUMN trigger_count INT DEFAULT 0 COMMENT '触发次数';
ALTER TABLE reward_rule ADD COLUMN start_time DATETIME COMMENT '生效开始时间';
ALTER TABLE reward_rule ADD COLUMN end_time DATETIME COMMENT '生效结束时间';
-- 3. 用户徽章表
CREATE TABLE user_badge (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
badge_code VARCHAR(50) NOT NULL COMMENT '徽章代码',
badge_name VARCHAR(100) NOT NULL COMMENT '徽章名称',
badge_icon VARCHAR(255) COMMENT '徽章图标',
source_type VARCHAR(50) COMMENT '来源类型',
source_id BIGINT COMMENT '来源ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_badge (user_id, badge_code),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户徽章';
-- 4. 用户称号表
CREATE TABLE user_title (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
title_code VARCHAR(50) NOT NULL COMMENT '称号代码',
title_name VARCHAR(100) NOT NULL COMMENT '称号名称',
is_active INT DEFAULT 0 COMMENT '是否激活显示',
expire_time DATETIME COMMENT '过期时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_title (user_id, title_code),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户称号';
```
### 5.2 后端 API 变更
| 接口 | 方法 | 说明 |
|------|------|------|
| /api/reward-rules | GET | 获取规则列表(增加统计数据) |
| /api/reward-rules/stats | GET | 获取奖励统计数据 |
| /api/reward-rules/preview | POST | 预览规则影响范围 |
| /api/reward-records | GET | 获取奖励发放记录 |
| /api/reward-records/my | GET | 获取我的奖励记录 |
| /api/users/{id}/badges | GET | 获取用户徽章 |
| /api/users/{id}/titles | GET | 获取用户称号 |
| /api/users/{id}/titles/active | PUT | 设置激活称号 |
### 5.3 前端组件
1. **RewardRuleEditor.vue** - 可视化规则编辑器
2. **RewardStats.vue** - 奖励统计面板
3. **RewardRecordList.vue** - 奖励记录列表
4. **UserBadgeWall.vue** - 用户徽章墙
5. **UserTitleSelect.vue** - 用户称号选择器
---
## 六、实施计划
### 第一阶段:基础优化(预计 2 天)
- [ ] 新增奖励发放记录表
- [ ] 实现奖励记录的存储和查询
- [ ] 规则列表增加统计数据显示
- [ ] 优化规则卡片UI
### 第二阶段:功能扩展(预计 3 天)
- [ ] 扩展触发条件类型
- [ ] 支持复合条件配置
- [ ] 实现规则预览功能
- [ ] 新增徽章和称号奖励
### 第三阶段:统计分析(预计 2 天)
- [ ] 实现奖励统计面板
- [ ] 奖励趋势图表
- [ ] 规则效果分析
### 第四阶段:用户中心(预计 2 天)
- [ ] 用户奖励历史页面
- [ ] 徽章墙功能
- [ ] 称号管理功能
---
## 七、预期效果
1. **管理效率提升**:可视化配置,减少配置错误
2. **激励效果增强**:多样化奖励,提升用户参与度
3. **数据驱动决策**:清晰的统计数据,优化奖励策略
4. **用户体验优化**:清晰的奖励反馈,增强成就感
---
## 八、风险与应对
| 风险 | 影响 | 应对措施 |
|------|------|----------|
| 奖励发放过多 | 积分通胀 | 设置规则预算上限 |
| 条件配置复杂 | 用户困惑 | 提供常用模板 |
| 性能压力 | 系统变慢 | 异步处理+缓存 |
| 规则冲突 | 重复奖励 | 优先级+互斥设置 |
---
*文档版本:v1.0*
*创建时间:2026-01-04*