first commit

This commit is contained in:
2026-09-21 19:21:35 +08:00
commit 6c944c4ffd
2733 changed files with 296956 additions and 0 deletions
@@ -0,0 +1,28 @@
package com.ruoyi.promo.config;
import com.ruoyi.promo.interceptor.PromoMaintenanceInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 推广后台 Web 配置:注册维护拦截器。
* <p>
* 对 /promo/** 生效,放行登录/注册接口(登录/注册由 PromoLoginController 自行提示维护)。
*
* @author ruoyi
*/
@Configuration
public class PromoWebConfig implements WebMvcConfigurer {
@Autowired
private PromoMaintenanceInterceptor promoMaintenanceInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(promoMaintenanceInterceptor)
.addPathPatterns("/promo/**")
.excludePathPatterns("/promo/login", "/promo/smsLogin", "/promo/register");
}
}
@@ -0,0 +1,17 @@
package com.ruoyi.promo.contract.request;
import lombok.Data;
@Data
public class GetPurchaseByUserIdRequest {
/**
* 玩家ID
*/
private Integer userId;
private String beginTime;
private String endTime;
private Integer pageNum;
private Integer pageSize;
}
@@ -0,0 +1,18 @@
package com.ruoyi.promo.contract.request;
import lombok.Data;
@Data
public class GetSubBranchesRequest {
/**
* 主播ID
*/
private Integer userId;
/**
* 玩家id
*/
private Integer playerId;
private Integer pageNum;
private Integer pageSize;
}
@@ -0,0 +1,11 @@
package com.ruoyi.promo.contract.request;
import lombok.Data;
@Data
public class QueryPromoteWelfareRequest {
private Integer pageNum;
private Integer pageSize;
private String beginTime;
private String endTime;
}
@@ -0,0 +1,22 @@
package com.ruoyi.promo.contract.request;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class RegisterRequest {
@NotNull(message = "用户名不能为空")
private String username;
@NotNull(message = "密码不能为空")
private String password;
private String contactInformation;
private String parentInvitationCode;
@NotNull(message = "注册角色不能为空")
private String userType;
private String code;
}
@@ -0,0 +1,13 @@
package com.ruoyi.promo.contract.request;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class UpdateInvitationCodeRequest {
@NotNull(message = "用户名不能为空")
private String username;
@NotNull(message = "邀请码不能为空")
private String invitationCode;
}
@@ -0,0 +1,10 @@
package com.ruoyi.promo.contract.response;
import lombok.Data;
@Data
public class GetShareDomainResponse {
private String zhuboShareDomain;
private String zhaoshaoShareDomain;
private String userShareDomain;
}
@@ -0,0 +1,35 @@
package com.ruoyi.promo.contract.response;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
@Data
public class GetSubBranchesResponse {
private Integer total;
private List<UserInfo> rows;
@Data
public static class UserInfo {
private Integer userId;
private String userName;
private String nickName;
//01主播 02普通用户
private String userType;
/**
* 佣金比例
*/
private BigDecimal commissionRate;
private Date createTime;
private Date updateTime;
private BigDecimal totalRecharge;
private BigDecimal totalCost;
private BigDecimal totalNormGameCost;
private BigDecimal totalSpecialGameCost;
}
}
@@ -0,0 +1,19 @@
package com.ruoyi.promo.contract.response;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
public class QueryPromotionWelfareResponse {
private Integer total;
private List<Record> rows;
private BigDecimal totalReward;
@Data
public static class Record {
private BigDecimal reward;
private String date;
}
}
@@ -0,0 +1,13 @@
package com.ruoyi.promo.contract.vo;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class DailyUserErcashVO {
private String date;
private BigDecimal dailyNormGameCost;
private BigDecimal dailySpecialGameCost;
private BigDecimal dailyRecharge;
}
@@ -0,0 +1,529 @@
package com.ruoyi.promo.controller;
import cn.hutool.core.lang.Validator;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.ruoyi.admin.mapper.PromoAdminMapper;
import com.ruoyi.admin.mapper.TtUserBlendErcashMapper;
import com.ruoyi.admin.mapper.TtUserMapper;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.domain.common.constant.TtAccountRecordSource;
import com.ruoyi.domain.common.constant.TtAccountRecordType;
import com.ruoyi.domain.common.constant.UserType;
import com.ruoyi.domain.entity.sys.PromoAdmin;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.promo.domain.entity.PromoCardKey;
import com.ruoyi.promo.mapper.PromoCardKeyMapper;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
/**
* 超级管理员专属管理接口
*/
@Api(tags = "超管管理")
@RestController
@RequestMapping("/promo/adminManage")
public class PromoAdminManageController extends BaseController {
@Autowired
private TtUserMapper ttUserMapper;
@Autowired
private TtUserBlendErcashMapper ttUserBlendErcashMapper;
@Autowired
private PromoCardKeyMapper promoCardKeyMapper;
@Autowired
private PromoAdminMapper promoAdminMapper;
/**
* 检查是否为超级管理员
*/
private boolean isSuperAdmin() {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return false;
}
return UserType.SUPER_ADMIN.getCode().equals(loginUser.getUserData().getUserType());
}
/**
* 招商管理:查看所有招商及其下属主播汇总数据
*/
@ApiOperation("招商管理列表")
@GetMapping("/businessList")
public R<List<Map<String, Object>>> getBusinessList() {
if (!isSuperAdmin()) {
return R.fail("无权限,仅超级管理员可访问");
}
// 查询所有招商用户(排除白号:白号数据不在招商管理体现)
LambdaQueryWrapper<TtUser> wrapper = Wrappers.<TtUser>lambdaQuery()
.eq(TtUser::getUserType, UserType.BUSINESS_USER.getCode())
.and(w -> w.isNull(TtUser::getIsBlankAccount).or().ne(TtUser::getIsBlankAccount, 1));
List<TtUser> businessUsers = ttUserMapper.selectList(wrapper);
List<Map<String, Object>> result = new ArrayList<>();
for (TtUser business : businessUsers) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("userId", business.getUserId());
item.put("userName", business.getUserName());
item.put("nickName", business.getNickName());
item.put("createTime", business.getCreateTime());
// 查询下属主播(排除白号)
LambdaQueryWrapper<TtUser> anchorWrapper = Wrappers.<TtUser>lambdaQuery()
.eq(TtUser::getParentId, business.getUserId())
.and(w -> w.isNull(TtUser::getIsBlankAccount).or().ne(TtUser::getIsBlankAccount, 1));
List<TtUser> anchors = ttUserMapper.selectList(anchorWrapper);
item.put("anchorCount", anchors.size());
// 汇总下属主播的下属用户数据
BigDecimal totalRecharge = BigDecimal.ZERO;
BigDecimal totalTurnover = BigDecimal.ZERO;
BigDecimal totalSpecialTurnover = BigDecimal.ZERO;
BigDecimal totalClassicTurnover = BigDecimal.ZERO;
BigDecimal totalEarnings = BigDecimal.ZERO; // 招商总收益
List<Map<String, Object>> anchorDetails = new ArrayList<>();
for (TtUser anchor : anchors) {
// 获取主播下属的所有普通用户(排除白号,白号充值/流水不计入统计)
List<Integer> subUserIds = filterOutBlankAccounts(ttUserMapper.allEmployeesByParents(
Collections.singletonList(anchor.getUserId())));
Map<String, Object> anchorItem = new LinkedHashMap<>();
anchorItem.put("userId", anchor.getUserId());
anchorItem.put("userName", anchor.getUserName());
anchorItem.put("nickName", anchor.getNickName());
anchorItem.put("userCount", subUserIds != null ? subUserIds.size() : 0);
BigDecimal anchorRecharge = BigDecimal.ZERO;
BigDecimal anchorTurnover = BigDecimal.ZERO;
BigDecimal anchorSpecialTurnover = BigDecimal.ZERO; // 特殊游戏(玫瑰女郎)流水
BigDecimal anchorClassicTurnover = BigDecimal.ZERO; // 经典游戏流水
BigDecimal anchorEarnings = BigDecimal.ZERO;
BigDecimal businessEarningsForAnchor = BigDecimal.ZERO; // 招商从该主播获得的收益
// 主播本人充值(无论有没有下级都需要查)
BigDecimal anchorSelfRecharge = sumBlendErcash(
Collections.singletonList(anchor.getUserId()),
TtAccountRecordSource.RECHARGE.getCode(),
TtAccountRecordType.INPUT.getCode());
if (CollectionUtils.isNotEmpty(subUserIds)) {
// 总充值
anchorRecharge = sumBlendErcash(subUserIds,
TtAccountRecordSource.RECHARGE.getCode(),
TtAccountRecordType.INPUT.getCode());
// 总流水(游戏消费)
anchorTurnover = sumGameConsume(subUserIds);
// 特殊游戏(玫瑰女郎)消费
anchorSpecialTurnover = sumNewGameConsume(subUserIds);
// 经典游戏消费
anchorClassicTurnover = sumClassicGameConsume(subUserIds);
BigDecimal newGameConsume = anchorSpecialTurnover;
BigDecimal classicGameConsume = anchorClassicTurnover;
// 主播收益 = 下级充值×主播充值比例 + 下级新游戏消费×主播新游戏比例 + 下级经典游戏消费×主播经典比例
anchorEarnings = calcRate(anchorRecharge, anchor.getCommissionRate())
.add(calcRate(newGameConsume, anchor.getNewGameCommissionRate()))
.add(calcRate(classicGameConsume, anchor.getClassicGameCommissionRate()));
// 招商收益 = 主播本人充值×招商充值比例
// + 主播下级充值×招商充值比例
// + 主播下级新游戏消费×招商新游戏比例
// + 主播下级经典游戏消费×招商经典比例
businessEarningsForAnchor = calcRate(anchorSelfRecharge, business.getCommissionRate())
.add(calcRate(anchorRecharge, business.getCommissionRate()))
.add(calcRate(newGameConsume, business.getNewGameCommissionRate()))
.add(calcRate(classicGameConsume, business.getClassicGameCommissionRate()));
// 每个普通用户详细数据
LambdaQueryWrapper<TtUser> userWrapper = Wrappers.<TtUser>lambdaQuery()
.in(TtUser::getUserId, subUserIds)
.orderByDesc(TtUser::getCreateTime);
List<TtUser> subUsers = ttUserMapper.selectList(userWrapper);
List<Map<String, Object>> userDetails = new ArrayList<>();
for (TtUser user : subUsers) {
Map<String, Object> userItem = new LinkedHashMap<>();
userItem.put("userId", user.getUserId());
userItem.put("userName", user.getUserName());
userItem.put("nickName", user.getNickName());
userItem.put("createTime", user.getCreateTime());
List<Integer> singleList = Collections.singletonList(user.getUserId());
userItem.put("totalRecharge", sumBlendErcash(singleList,
TtAccountRecordSource.RECHARGE.getCode(),
TtAccountRecordType.INPUT.getCode()));
userItem.put("totalTurnover", sumGameConsume(singleList));
userItem.put("specialGameTurnover", sumNewGameConsume(singleList));
userItem.put("classicGameTurnover", sumClassicGameConsume(singleList));
userDetails.add(userItem);
}
anchorItem.put("users", userDetails);
}
anchorItem.put("totalRecharge", anchorRecharge);
BigDecimal anchorSelf = anchorSelfRecharge != null ? anchorSelfRecharge : BigDecimal.ZERO;
anchorItem.put("selfRecharge", anchorSelf);
anchorItem.put("totalTurnover", anchorTurnover);
anchorItem.put("specialGameTurnover", anchorSpecialTurnover);
anchorItem.put("classicGameTurnover", anchorClassicTurnover);
anchorItem.put("totalEarnings", anchorEarnings);
anchorDetails.add(anchorItem);
// 招商总充值 = 主播本人充值 + 主播下级充值
totalRecharge = totalRecharge.add(anchorRecharge).add(anchorSelf);
totalTurnover = totalTurnover.add(anchorTurnover);
totalSpecialTurnover = totalSpecialTurnover.add(anchorSpecialTurnover);
totalClassicTurnover = totalClassicTurnover.add(anchorClassicTurnover);
totalEarnings = totalEarnings.add(businessEarningsForAnchor);
}
item.put("totalRecharge", totalRecharge);
item.put("totalTurnover", totalTurnover);
item.put("specialGameTurnover", totalSpecialTurnover);
item.put("classicGameTurnover", totalClassicTurnover);
item.put("totalEarnings", totalEarnings);
item.put("anchors", anchorDetails);
result.add(item);
}
return R.ok(result);
}
/**
* 主播管理:查看所有主播及其下属用户汇总数据
*/
@ApiOperation("主播管理列表")
@GetMapping("/anchorList")
public R<List<Map<String, Object>>> getAnchorList() {
if (!isSuperAdmin()) {
return R.fail("无权限,仅超级管理员可访问");
}
// 查询所有主播用户(排除白号:白号数据不在主播管理体现)
LambdaQueryWrapper<TtUser> wrapper = Wrappers.<TtUser>lambdaQuery()
.eq(TtUser::getUserType, UserType.ANCHOR.getCode())
.and(w -> w.isNull(TtUser::getIsBlankAccount).or().ne(TtUser::getIsBlankAccount, 1));
List<TtUser> anchors = ttUserMapper.selectList(wrapper);
List<Map<String, Object>> result = new ArrayList<>();
for (TtUser anchor : anchors) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("userId", anchor.getUserId());
item.put("userName", anchor.getUserName());
item.put("nickName", anchor.getNickName());
item.put("parentId", anchor.getParentId());
item.put("createTime", anchor.getCreateTime());
// 查询下属用户(排除白号,白号充值/流水不计入统计)
List<Integer> subUserIds = filterOutBlankAccounts(ttUserMapper.allEmployeesByParents(
Collections.singletonList(anchor.getUserId())));
item.put("userCount", subUserIds != null ? subUserIds.size() : 0);
BigDecimal totalRecharge = BigDecimal.ZERO;
BigDecimal totalTurnover = BigDecimal.ZERO;
BigDecimal totalEarnings = BigDecimal.ZERO;
// 下级聚合分项,无下属时保持 0,仅主播本人充值返利生效
BigDecimal newGameConsume = BigDecimal.ZERO;
BigDecimal classicGameConsume = BigDecimal.ZERO;
// 主播本人充值(用于主播本人充值返利分项,口径与 getBusinessList 完全一致)
BigDecimal anchorSelfRecharge = sumBlendErcash(
Collections.singletonList(anchor.getUserId()),
TtAccountRecordSource.RECHARGE.getCode(),
TtAccountRecordType.INPUT.getCode());
List<Map<String, Object>> userDetails = new ArrayList<>();
if (CollectionUtils.isNotEmpty(subUserIds)) {
// 总充值
totalRecharge = sumBlendErcash(subUserIds,
TtAccountRecordSource.RECHARGE.getCode(),
TtAccountRecordType.INPUT.getCode());
// 总流水(游戏消费)
totalTurnover = sumGameConsume(subUserIds);
// 新游戏消费
newGameConsume = sumNewGameConsume(subUserIds);
// 经典游戏消费
classicGameConsume = sumClassicGameConsume(subUserIds);
// 每个用户详细
LambdaQueryWrapper<TtUser> userWrapper = Wrappers.<TtUser>lambdaQuery()
.in(TtUser::getUserId, subUserIds);
List<TtUser> subUsers = ttUserMapper.selectList(userWrapper);
for (TtUser user : subUsers) {
Map<String, Object> userItem = new LinkedHashMap<>();
userItem.put("userId", user.getUserId());
userItem.put("userName", user.getUserName());
userItem.put("nickName", user.getNickName());
userItem.put("createTime", user.getCreateTime());
List<Integer> singleList = Collections.singletonList(user.getUserId());
userItem.put("totalRecharge", sumBlendErcash(singleList,
TtAccountRecordSource.RECHARGE.getCode(),
TtAccountRecordType.INPUT.getCode()));
userItem.put("totalTurnover", sumGameConsume(singleList));
userItem.put("specialGameTurnover", sumNewGameConsume(singleList));
userItem.put("classicGameTurnover", sumClassicGameConsume(singleList));
userDetails.add(userItem);
}
}
// 主播收益 = 主播本人充值×主播充值比例 + 下级充值×主播充值比例
// + 下级新游戏消费×主播新游戏比例 + 下级经典游戏消费×主播经典比例
// 分项粒度与舍入口径与 getBusinessList 完全一致(含主播本人充值返利)
// 无条件计算:无下属时后三项为 0,仅主播本人充值返利生效
totalEarnings = calcRate(anchorSelfRecharge, anchor.getCommissionRate())
.add(calcRate(totalRecharge, anchor.getCommissionRate()))
.add(calcRate(newGameConsume, anchor.getNewGameCommissionRate()))
.add(calcRate(classicGameConsume, anchor.getClassicGameCommissionRate()));
item.put("totalRecharge", totalRecharge);
item.put("totalTurnover", totalTurnover);
item.put("totalEarnings", totalEarnings);
item.put("users", userDetails);
result.add(item);
}
return R.ok(result);
}
/**
* 卡商管理:查看所有卡商及其卡密记录
*/
@ApiOperation("卡商管理列表")
@GetMapping("/cardMerchantList")
public R<List<Map<String, Object>>> getCardMerchantList() {
if (!isSuperAdmin()) {
return R.fail("无权限,仅超级管理员可访问");
}
// 查询所有卡商用户(排除已删除的)
LambdaQueryWrapper<TtUser> wrapper = Wrappers.<TtUser>lambdaQuery()
.eq(TtUser::getUserType, UserType.CARD_MERCHANT.getCode())
.eq(TtUser::getDelFlag, "0");
List<TtUser> merchants = ttUserMapper.selectList(wrapper);
List<Map<String, Object>> result = new ArrayList<>();
for (TtUser merchant : merchants) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("userId", merchant.getUserId());
item.put("userName", merchant.getUserName());
item.put("nickName", merchant.getNickName());
item.put("merchantBalance", merchant.getMerchantBalance() != null ? merchant.getMerchantBalance() : BigDecimal.ZERO);
item.put("createTime", merchant.getCreateTime());
// 查询该卡商的所有卡密
LambdaQueryWrapper<PromoCardKey> cardWrapper = Wrappers.<PromoCardKey>lambdaQuery()
.eq(PromoCardKey::getCreatorId, merchant.getUserId())
.orderByDesc(PromoCardKey::getCreateTime);
List<PromoCardKey> cardKeys = promoCardKeyMapper.selectList(cardWrapper);
item.put("totalCards", cardKeys.size());
item.put("usedCards", cardKeys.stream().filter(c -> c.getStatus() == 1).count());
item.put("cardKeys", cardKeys);
result.add(item);
}
return R.ok(result);
}
/**
* 过滤掉白号用户ID:白号(is_blank_account=1)的充值/流水为虚假数据,
* 不计入招商/主播管理的金额与收益统计。
*/
private List<Integer> filterOutBlankAccounts(List<Integer> userIds) {
if (CollectionUtils.isEmpty(userIds)) {
return userIds;
}
return ttUserMapper.selectList(Wrappers.<TtUser>lambdaQuery()
.select(TtUser::getUserId)
.in(TtUser::getUserId, userIds)
.and(w -> w.isNull(TtUser::getIsBlankAccount).or().ne(TtUser::getIsBlankAccount, 1)))
.stream().map(TtUser::getUserId).collect(Collectors.toList());
}
/**
* 汇总指定用户的特定来源流水金额(同时查询主表和归档表)
*/
private BigDecimal sumBlendErcash(List<Integer> userIds, Integer source, Integer type) {
if (CollectionUtils.isEmpty(userIds)) {
return BigDecimal.ZERO;
}
BigDecimal result = ttUserBlendErcashMapper.sumAmountByUserIdsAndSource(userIds, source, type);
return result != null ? result : BigDecimal.ZERO;
}
/**
* 汇总游戏消费流水(总流水,同时查询主表和归档表)
*/
private BigDecimal sumGameConsume(List<Integer> userIds) {
if (CollectionUtils.isEmpty(userIds)) {
return BigDecimal.ZERO;
}
Set<Integer> consumeCodes = TtAccountRecordSource.getGameConsumeCodes();
BigDecimal result = ttUserBlendErcashMapper.sumAmountByUserIdsAndSources(
userIds, new ArrayList<>(consumeCodes), null);
return result != null ? result : BigDecimal.ZERO;
}
/**
* 汇总新游戏消费(奥林匹斯)
*/
private BigDecimal sumNewGameConsume(List<Integer> userIds) {
return sumConsumeByFilter(userIds, TtAccountRecordSource::isNewGameConsumeForCommission);
}
/**
* 汇总经典游戏消费(经典开箱、对战、幸运升级、疯狂转盘)
*/
private BigDecimal sumClassicGameConsume(List<Integer> userIds) {
return sumConsumeByFilter(userIds, TtAccountRecordSource::isClassicGameConsumeForCommission);
}
private BigDecimal sumConsumeByFilter(List<Integer> userIds, java.util.function.IntPredicate filter) {
if (CollectionUtils.isEmpty(userIds)) return BigDecimal.ZERO;
Set<Integer> consumeCodes = TtAccountRecordSource.getGameConsumeCodes();
Set<Integer> filteredCodes = consumeCodes.stream().filter(filter::test).collect(Collectors.toSet());
if (filteredCodes.isEmpty()) return BigDecimal.ZERO;
BigDecimal result = ttUserBlendErcashMapper.sumAmountByUserIdsAndSources(
userIds, new ArrayList<>(filteredCodes), null);
return result != null ? result : BigDecimal.ZERO;
}
/**
* 按百分比比例计算佣金:amount × rate / 100
*/
private BigDecimal calcRate(BigDecimal amount, BigDecimal rate) {
if (amount == null || rate == null
|| amount.compareTo(BigDecimal.ZERO) <= 0
|| rate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
return amount.multiply(rate)
.divide(BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP);
}
// ==================== 推广后台管理员账号管理(仅超管) ====================
@ApiOperation("管理员列表")
@GetMapping("/adminList")
public R<List<PromoAdmin>> adminList() {
if (!isSuperAdmin()) {
return R.fail("无权限,仅超级管理员可访问");
}
List<PromoAdmin> list = promoAdminMapper.selectList(
Wrappers.<PromoAdmin>lambdaQuery().orderByAsc(PromoAdmin::getId));
list.forEach(a -> a.setPassword(null));
return R.ok(list);
}
@ApiOperation("新增/修改管理员")
@PostMapping("/saveAdmin")
public R<Void> saveAdmin(@RequestBody PromoAdmin body) {
if (!isSuperAdmin()) {
return R.fail("无权限,仅超级管理员可访问");
}
String username = StringUtils.trim(body.getUsername());
if (StringUtils.isEmpty(username)) {
return R.fail("登录账号不能为空");
}
if (!Validator.isMobile(username)) {
return R.fail("登录账号必须是手机号");
}
if (StringUtils.isEmpty(body.getNickName())) {
return R.fail("昵称不能为空");
}
String newPhone = StringUtils.isEmpty(body.getPhoneNumber())
? username : StringUtils.trim(body.getPhoneNumber());
PromoAdmin exist = promoAdminMapper.findByUsername(username);
if (body.getId() == null) {
if (StringUtils.isEmpty(body.getPassword())) {
return R.fail("密码不能为空");
}
if (exist != null) {
return R.fail("该账号已存在");
}
// tt_user 里已有该手机号(哪怕现在是普通用户)一律不给加:以后该号变成主播/招商/卡商,
// 短信登录会优先命中 tt_user,超管身份就丢了
if (existsInTtUser(username) || existsInTtUser(newPhone)) {
return R.fail("手机号'" + username + "'在用户表中已存在,不能添加为管理员");
}
body.setUsername(username);
body.setPassword(SecurityUtils.encryptPassword(body.getPassword()));
body.setStatus("0");
body.setCreateTime(DateUtils.getNowDate());
if (StringUtils.isEmpty(body.getPhoneNumber())) {
body.setPhoneNumber(username);
}
promoAdminMapper.insert(body);
return R.ok();
}
PromoAdmin target = promoAdminMapper.selectById(body.getId());
if (target == null) {
return R.fail("管理员不存在");
}
if (exist != null && !exist.getId().equals(target.getId())) {
return R.fail("该账号已存在");
}
// 只在改成新手机号时校验,避免老数据(手机号已在 tt_user)连昵称都改不了
if ((!username.equals(target.getUsername()) && existsInTtUser(username))
|| (!newPhone.equals(target.getPhoneNumber()) && existsInTtUser(newPhone))) {
return R.fail("该手机号在用户表中已存在,不能作为管理员账号");
}
target.setUsername(username);
target.setNickName(body.getNickName());
target.setPhoneNumber(newPhone);
target.setEmail(body.getEmail());
if (StringUtils.isNotEmpty(body.getPassword())) {
target.setPassword(SecurityUtils.encryptPassword(body.getPassword()));
}
promoAdminMapper.updateById(target);
return R.ok();
}
/**
* tt_user 里是否已有该手机号(登录账号或手机号任一命中即算)
*/
private boolean existsInTtUser(String phone) {
if (StringUtils.isEmpty(phone)) {
return false;
}
return new LambdaQueryChainWrapper<>(ttUserMapper)
.eq(TtUser::getDelFlag, "0")
.and(w -> w.eq(TtUser::getPhoneNumber, phone).or().eq(TtUser::getUserName, phone))
.count() > 0;
}
@ApiOperation("删除管理员")
@PostMapping("/deleteAdmin/{id}")
public R<Void> deleteAdmin(@PathVariable("id") Integer id) {
if (!isSuperAdmin()) {
return R.fail("无权限,仅超级管理员可访问");
}
if (id.equals(getUserId().intValue())) {
return R.fail("不能删除当前登录账号");
}
if (promoAdminMapper.selectCount(null) <= 1) {
return R.fail("至少保留一个管理员");
}
promoAdminMapper.deleteById(id);
return R.ok();
}
}
@@ -0,0 +1,101 @@
package com.ruoyi.promo.controller;
import cloud.tianai.captcha.application.ImageCaptchaApplication;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.generator.common.model.dto.GenerateParam;
import cloud.tianai.captcha.common.response.ApiResponse;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.promo.model.PromoCaptchaCheckRequest;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
* 推广后台滑块验证码控制器
* POST /promo/captcha/gen - 生成验证码
* POST /promo/captcha/check - 校验验证码
*/
@Api(tags = "推广后台滑块验证码")
@Slf4j
@RestController
@RequestMapping("/promo/captcha")
public class PromoCaptchaController extends BaseController {
@Autowired
private ImageCaptchaApplication application;
@Autowired
private RedisCache redisCache;
/** 滑块位置容差,0.3 表示允许 30% 偏差 */
private static final float SLIDER_TOLERANT = 0.05f;
/**
* 生成滑块验证码
*/
@ApiOperation("生成滑块验证码")
@Anonymous
@PostMapping("/gen")
public R<Object> genCaptcha() {
long start = System.currentTimeMillis();
GenerateParam generateParam = new GenerateParam();
generateParam.setType(CaptchaTypeConstant.SLIDER);
// 通过官方 API 设置容差,库内部生成和校验都会使用此值
generateParam.addParam("tolerant", SLIDER_TOLERANT);
ApiResponse<?> response = application.generateCaptcha(generateParam);
long cost = System.currentTimeMillis() - start;
if (cost > 500) {
log.warn("[推广后台验证码生成] 耗时 {}ms(较慢,请检查 local-cache 是否生效)", cost);
} else {
log.info("[推广后台验证码生成] 耗时 {}ms", cost);
}
if (!response.isSuccess()) {
return R.fail("验证码生成失败");
}
return R.ok(response.getData());
}
/**
* 校验滑块验证码
* 通过后在 Redis 写入 promo:captcha:verified:{phoneNumber} = captchaId,有效期 5 分钟
*/
@ApiOperation("校验滑块验证码")
@Anonymous
@PostMapping("/check")
public R<Object> checkCaptcha(@RequestBody PromoCaptchaCheckRequest request) {
if (StringUtils.isEmpty(request.getId())) {
return R.fail("验证码 ID 不能为空");
}
if (StringUtils.isEmpty(request.getPhoneNumber())) {
return R.fail("手机号不能为空");
}
ApiResponse<?> checkResponse = application.matching(request.getId(), request.getData());
if (!checkResponse.isSuccess()) {
log.warn("[推广后台滑块验证] 验证失败,captchaId={}, phone={}", request.getId(), request.getPhoneNumber());
return R.fail("滑块验证失败,请重试");
}
// 验证通过,写入 Redis 标记,5 分钟内可用于发短信或密码登录
String verifyKey = "promo:captcha:verified:" + request.getPhoneNumber();
redisCache.setCacheObject(verifyKey, request.getId(), 5, TimeUnit.MINUTES);
log.info("[推广后台滑块验证成功] phone={}", request.getPhoneNumber());
return R.ok("验证通过");
}
}
@@ -0,0 +1,165 @@
package com.ruoyi.promo.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.admin.config.RedisConstants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.redis.config.RedisLock;
import com.ruoyi.domain.common.constant.UserType;
import com.ruoyi.promo.domain.entity.PromoCardKey;
import com.ruoyi.promo.service.PromoCardKeyService;
import com.ruoyi.promo.service.PromoLoginService;
import com.ruoyi.domain.entity.TtRechargeProd;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Api(tags = "卡商卡密管理")
@RestController
@RequestMapping("/promo/cardKey")
public class PromoCardKeyController extends BaseController {
@Autowired
private PromoCardKeyService promoCardKeyService;
@Autowired
private PromoLoginService promoLoginService;
@Autowired
private RedisLock redisLock;
/**
* 获取卡商信息(名称、余额)
*/
@ApiOperation("获取卡商信息")
@GetMapping("/merchantInfo")
public R<Map<String, Object>> getMerchantInfo() {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
String userType = loginUser.getUserData().getUserType();
if (!UserType.CARD_MERCHANT.getCode().equals(userType)
&& !UserType.SUPER_ADMIN.getCode().equals(userType)) {
return R.fail("无权限");
}
Map<String, Object> info = promoCardKeyService.getMerchantInfo(getUserId());
return R.ok(info);
}
/**
* 获取充值列表(金额选项)
*/
@ApiOperation("获取充值金额列表")
@GetMapping("/rechargeList")
public R<List<TtRechargeProd>> getRechargeList() {
List<TtRechargeProd> list = promoLoginService.queryList();
return R.ok(list);
}
/**
* 生成卡密
*/
@ApiOperation("生成卡密")
@PostMapping("/generate")
public R<List<String>> generateCardKeys(@RequestBody Map<String, Object> request) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
String userType = loginUser.getUserData().getUserType();
if (!UserType.CARD_MERCHANT.getCode().equals(userType)
&& !UserType.SUPER_ADMIN.getCode().equals(userType)) {
return R.fail("无权限,仅卡商/管理员可生成卡密");
}
Object amountObj = request.get("amount");
Integer num = (Integer) request.get("num");
if (amountObj == null || num == null) {
return R.fail("参数不完整");
}
BigDecimal amount;
try {
amount = new BigDecimal(amountObj.toString());
} catch (NumberFormatException e) {
return R.fail("金额格式错误");
}
Long userId = getUserId();
String userName = loginUser.getUserData().getUserName();
// 防重:按卡商 userId 加锁(锁粒度=单卡商,不影响其他卡商),锁包裹整个 @Transactional 生成过程,
// 解锁晚于事务提交;卡密码每次都是新随机值,唯一索引挡不住重复提交,只能靠这把锁
String lockKey = RedisConstants.CARD_KEY_GENERATE_LOCK + userId;
if (!redisLock.tryLock(lockKey, 0L, 30L, TimeUnit.SECONDS)) {
return R.fail("卡密生成中,请勿重复提交");
}
try {
return promoCardKeyService.generateCardKeys(userId, userName, amount, num);
} finally {
redisLock.unlock(lockKey);
}
}
/**
* 销毁未使用卡密(软删除并按面值退回卡商余额;已使用的不可销毁)
*/
@ApiOperation("销毁卡密")
@PostMapping("/destroy")
public R<String> destroyCardKey(@RequestBody Map<String, Object> request) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
String userType = loginUser.getUserData().getUserType();
boolean isSuperAdmin = UserType.SUPER_ADMIN.getCode().equals(userType);
if (!UserType.CARD_MERCHANT.getCode().equals(userType) && !isSuperAdmin) {
return R.fail("无权限,仅卡商/管理员可销毁卡密");
}
Object idObj = request.get("id");
if (idObj == null) {
return R.fail("参数不完整");
}
Long id;
try {
id = Long.valueOf(idObj.toString());
} catch (NumberFormatException e) {
return R.fail("卡密ID格式错误");
}
return promoCardKeyService.destroyCardKey(id, getUserId(), isSuperAdmin);
}
/**
* 获取卡密列表
*/
@ApiOperation("获取卡密列表")
@GetMapping("/list")
public R<IPage<PromoCardKey>> getCardKeyList(
@RequestParam(required = false) String usedByName,
@RequestParam(required = false) String creatorName,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
String userType = loginUser.getUserData().getUserType();
if (!UserType.CARD_MERCHANT.getCode().equals(userType)
&& !UserType.SUPER_ADMIN.getCode().equals(userType)) {
return R.fail("无权限");
}
// 超管查看所有卡密,卡商只看自己的
Long creatorId = UserType.SUPER_ADMIN.getCode().equals(userType) ? null : getUserId();
IPage<PromoCardKey> page = promoCardKeyService.getCardKeyList(creatorId, usedByName, creatorName, pageNum, pageSize);
return R.ok(page);
}
}
@@ -0,0 +1,167 @@
package com.ruoyi.promo.controller;
import com.ruoyi.admin.config.RedisConstants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.redis.config.RedisLock;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.domain.common.constant.UserType;
import com.ruoyi.promo.domain.entity.PromoCdkey;
import com.ruoyi.promo.domain.entity.PromoCdkeyRedeem;
import com.ruoyi.promo.service.PromoCdkeyService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 推广CDKEY管理
*/
@Api(tags = "推广CDKEY管理")
@RestController
@RequestMapping("/promo/cdkey")
public class PromoCdkeyController extends BaseController {
@Autowired
private PromoCdkeyService promoCdkeyService;
@Autowired
private RedisLock redisLock;
/**
* 生成CDKEY(仅主播账号可操作)
*/
@ApiOperation("生成CDKEY")
@PostMapping("/generate")
public R<List<String>> generate(@RequestBody Map<String, Object> form) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
String userType = loginUser.getUserData().getUserType();
if (!UserType.ANCHOR.getCode().equals(userType)
&& !UserType.SUPER_ADMIN.getCode().equals(userType)) {
return R.fail("无权限,仅主播账号可创建CDKEY");
}
Long userId = getUserId();
String userName = loginUser.getUserData().getUserName();
// 防重:按创建者 userId 加锁(锁粒度=单创建者,不影响其他人)。
// 该接口按「最高金额×可用次数×生成数量」预扣主播余额,重复提交会预扣两次,
// 且 service 上的 @Transactional 在锁内的方法返回时提交,解锁晚于事务提交
String lockKey = RedisConstants.PROMO_CDKEY_GENERATE_LOCK + userId;
if (!redisLock.tryLock(lockKey, 0L, 30L, TimeUnit.SECONDS)) {
return R.fail("CDKEY生成中,请勿重复提交");
}
try {
return promoCdkeyService.generateCdkeys(userId, userName, form);
} finally {
redisLock.unlock(lockKey);
}
}
/**
* 获取创建CDKEY记录列表
*/
@ApiOperation("获取创建CDKEY记录列表")
@GetMapping("/createList")
public R<List<PromoCdkey>> createList(
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
@RequestParam(required = false) Integer status) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
Long userId = getUserId();
Date start = parseDate(startDate, " 00:00:00");
Date end = parseDate(endDate, " 23:59:59");
List<PromoCdkey> list = promoCdkeyService.getCdkeyList(userId, start, end, status);
return R.ok(list);
}
/**
* 获取兑换CDKEY记录列表
*/
@ApiOperation("获取兑换CDKEY记录列表")
@GetMapping("/redeemList")
public R<List<PromoCdkeyRedeem>> redeemList(
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String redeemer,
@RequestParam(required = false) String cdkey) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
Long userId = getUserId();
Date start = parseDate(startDate, " 00:00:00");
Date end = parseDate(endDate, " 23:59:59");
List<PromoCdkeyRedeem> list = promoCdkeyService.getRedeemList(userId, start, end, redeemer, cdkey);
return R.ok(list);
}
/**
* 创建CDKEY日报
*/
@ApiOperation("创建CDKEY日报")
@GetMapping("/createDaily")
public R<List<Map<String, Object>>> createDaily(
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
Long userId = getUserId();
Date start = parseDate(startDate, " 00:00:00");
Date end = parseDate(endDate, " 23:59:59");
List<Map<String, Object>> list = promoCdkeyService.getCreateDailyReport(userId, start, end);
return R.ok(list);
}
/**
* 兑换CDKEY日报
*/
@ApiOperation("兑换CDKEY日报")
@GetMapping("/redeemDaily")
public R<List<Map<String, Object>>> redeemDaily(
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
Long userId = getUserId();
Date start = parseDate(startDate, " 00:00:00");
Date end = parseDate(endDate, " 23:59:59");
List<Map<String, Object>> list = promoCdkeyService.getRedeemDailyReport(userId, start, end);
return R.ok(list);
}
/**
* 解析日期字符串(yyyy-MM-dd)
*/
private Date parseDate(String dateStr, String timeSuffix) {
if (StringUtils.isEmpty(dateStr)) {
return null;
}
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return sdf.parse(dateStr + timeSuffix);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,362 @@
package com.ruoyi.promo.controller;
import cn.hutool.core.lang.Validator;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.annotation.UpdateUserCache;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.domain.entity.UserData;
import com.ruoyi.common.core.page.PageDataInfo;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.domain.common.constant.UserType;
import com.ruoyi.domain.entity.TtRechargeProd;
import com.ruoyi.domain.entity.sys.PromoAdmin;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.admin.config.RedisConstants;
import com.ruoyi.admin.mapper.PromoAdminMapper;
import com.ruoyi.admin.mapper.TtUserMapper;
import com.ruoyi.common.redis.config.RedisLock;
import com.ruoyi.domain.other.ApiLoginBody;
import com.ruoyi.domain.other.ApiVerificationCodeLoginBody;
import com.ruoyi.promo.contract.request.RegisterRequest;
import com.ruoyi.promo.contract.request.UpdateInvitationCodeRequest;
import com.ruoyi.promo.contract.response.GetShareDomainResponse;
import com.ruoyi.promo.service.PromoLoginService;
import com.ruoyi.system.service.ISysConfigService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashMap;
import java.util.concurrent.TimeUnit;
@Api(tags = "推广后台用户认证")
@RestController
@RequestMapping("/promo")
public class PromoLoginController extends BaseController {
@Autowired
private PromoLoginService promoLoginService;
@Autowired
private TtUserMapper ttUserMapper;
@Autowired
private RedisCache redisCache;
@Autowired
private ISysConfigService configService;
@Autowired
private PromoAdminMapper promoAdminMapper;
@Autowired
private RedisLock redisLock;
@ApiOperation("获取推广后台用户信息")
@PostMapping("/getInfo")
public R<Map<String, Object>> getPromoInfo() {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
UserData userData = loginUser.getUserData();
Map<String, Object> info = new LinkedHashMap<>();
info.put("userId", userData.getUserId());
info.put("userName", userData.getUserName());
info.put("userType", userData.getUserType());
// 超管数据来自 promo_admin 表:userId 与 tt_user 不同表,绝不能按 id 去查 tt_user
if (UserType.SUPER_ADMIN.getCode().equals(userData.getUserType())) {
PromoAdmin admin = promoAdminMapper.selectById(userData.getUserId());
info.put("nickName", admin != null ? admin.getNickName() : userData.getNickName());
info.put("phoneNumber", admin != null ? admin.getPhoneNumber() : "");
info.put("email", admin != null ? admin.getEmail() : "");
info.put("avatar", admin != null && admin.getAvatar() != null ? admin.getAvatar() : "");
info.put("createTime", admin != null && admin.getCreateTime() != null
? new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(admin.getCreateTime()) : null);
info.put("invitationCode", "");
info.put("parentInvitationCode", null);
info.put("invitationCodeModified", false);
info.put("accountAmount", BigDecimal.ZERO);
info.put("accountCredits", BigDecimal.ZERO);
info.put("steamId", null);
info.put("transactionLink", null);
info.put("commissionRate", BigDecimal.ZERO);
info.put("newGameCommissionRate", BigDecimal.ZERO);
info.put("classicGameCommissionRate", BigDecimal.ZERO);
return R.ok(info);
}
// 查询数据库获取完整用户信息
TtUser ttUser = ttUserMapper.selectById(userData.getUserId());
info.put("nickName", ttUser != null ? ttUser.getNickName() : userData.getNickName());
info.put("phoneNumber", ttUser != null ? ttUser.getPhoneNumber() : "");
info.put("email", ttUser != null ? ttUser.getEmail() : "");
info.put("avatar", ttUser != null && ttUser.getAvatar() != null ? ttUser.getAvatar() : "");
info.put("invitationCode", ttUser != null && ttUser.getInvitationCode() != null ? ttUser.getInvitationCode() : "");
info.put("accountAmount", ttUser != null ? ttUser.getAccountAmount() : 0);
info.put("accountCredits", ttUser != null ? ttUser.getAccountCredits() : 0);
info.put("steamId", ttUser != null ? ttUser.getSteamId() : null);
info.put("transactionLink", ttUser != null ? ttUser.getTransactionLink() : null);
// 返佣比例:用于首页游戏返利看板展示
info.put("commissionRate", ttUser != null && ttUser.getCommissionRate() != null ? ttUser.getCommissionRate() : BigDecimal.ZERO);
info.put("newGameCommissionRate", ttUser != null && ttUser.getNewGameCommissionRate() != null ? ttUser.getNewGameCommissionRate() : BigDecimal.ZERO);
info.put("classicGameCommissionRate", ttUser != null && ttUser.getClassicGameCommissionRate() != null ? ttUser.getClassicGameCommissionRate() : BigDecimal.ZERO);
// 注册时间
if (ttUser != null && ttUser.getCreateTime() != null) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
info.put("createTime", sdf.format(ttUser.getCreateTime()));
} else {
info.put("createTime", null);
}
// 上级邀请码:通过parentId查询上级用户的邀请码
if (ttUser != null && ttUser.getParentId() != null) {
TtUser parentUser = ttUserMapper.selectById(ttUser.getParentId());
info.put("parentInvitationCode", parentUser != null ? parentUser.getInvitationCode() : null);
} else {
info.put("parentInvitationCode", null);
}
// 邀请码是否已修改过(只允许修改一次)
boolean invitationCodeModified = Boolean.TRUE.equals(
redisCache.hasKey("promo:invitation_code_modified:" + userData.getUserId()));
info.put("invitationCodeModified", invitationCodeModified);
return R.ok(info);
}
@ApiOperation("修改个人资料(昵称/手机号/邮箱)")
@PostMapping("/updateProfile")
public R<Void> updateProfile(@RequestBody Map<String, String> body) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
UserData userData = loginUser.getUserData();
String nickName = StringUtils.trim(body.get("nickName"));
String phoneNumber = StringUtils.trim(body.get("phoneNumber"));
String email = StringUtils.trim(body.get("email"));
if (StringUtils.isEmpty(nickName)) {
return R.fail("昵称不能为空");
}
if (StringUtils.isNotEmpty(phoneNumber) && !Validator.isMobile(phoneNumber)) {
return R.fail("手机号格式错误,请检查手机号是否输入正确!");
}
if (UserType.SUPER_ADMIN.getCode().equals(userData.getUserType())) {
PromoAdmin admin = promoAdminMapper.selectById(userData.getUserId());
if (admin == null) {
return R.fail("管理员不存在");
}
if (StringUtils.isNotEmpty(phoneNumber)) {
PromoAdmin other = promoAdminMapper.findByAccount(phoneNumber);
if (other != null && !other.getId().equals(admin.getId())) {
return R.fail("该手机号已被其他管理员使用");
}
}
admin.setNickName(nickName);
admin.setPhoneNumber(phoneNumber);
admin.setEmail(email);
promoAdminMapper.updateById(admin);
return R.ok();
}
// 招商/主播/卡商:手机号即登录账号,不允许在这里修改
TtUser user = ttUserMapper.selectById(userData.getUserId());
if (user == null) {
return R.fail("用户不存在");
}
if (StringUtils.isNotEmpty(phoneNumber) && !phoneNumber.equals(user.getPhoneNumber())) {
return R.fail("手机号不可修改,请联系管理员");
}
TtUser update = new TtUser();
update.setUserId(user.getUserId());
update.setNickName(nickName);
update.setEmail(email);
ttUserMapper.updateById(update);
return R.ok();
}
@ApiOperation("修改头像(超管,前端裁剪后的 base64)")
@PostMapping("/updateAvatar")
public R<Void> updateAvatar(@RequestBody Map<String, String> body) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
UserData userData = loginUser.getUserData();
if (!UserType.SUPER_ADMIN.getCode().equals(userData.getUserType())) {
return R.fail("该角色请通过 App 修改头像");
}
String avatar = StringUtils.trim(body.get("avatar"));
if (StringUtils.isEmpty(avatar) || !avatar.startsWith("data:image/")) {
return R.fail("头像数据不正确");
}
// 前端已裁剪为 200x200 JPEG,超过 500KB 说明没裁剪,直接拒绝
if (avatar.length() > 500_000) {
return R.fail("头像过大,请重新裁剪");
}
PromoAdmin admin = promoAdminMapper.selectById(userData.getUserId());
if (admin == null) {
return R.fail("管理员不存在");
}
PromoAdmin update = new PromoAdmin();
update.setId(admin.getId());
update.setAvatar(avatar);
promoAdminMapper.updateById(update);
return R.ok();
}
@ApiOperation("修改登录密码")
@PostMapping("/updatePassword")
public R<Void> updatePassword(@RequestBody Map<String, String> body) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
UserData userData = loginUser.getUserData();
String oldPassword = StringUtils.trim(body.get("oldPassword"));
String newPassword = StringUtils.trim(body.get("newPassword"));
if (StringUtils.isEmpty(oldPassword) || StringUtils.isEmpty(newPassword)) {
return R.fail("密码不能为空");
}
if (newPassword.length() < 6) {
return R.fail("新密码长度不能少于6位");
}
if (UserType.SUPER_ADMIN.getCode().equals(userData.getUserType())) {
PromoAdmin admin = promoAdminMapper.selectById(userData.getUserId());
if (admin == null) {
return R.fail("管理员不存在");
}
if (!SecurityUtils.matchesPassword(oldPassword, admin.getPassword())) {
return R.fail("旧密码错误");
}
PromoAdmin update = new PromoAdmin();
update.setId(admin.getId());
update.setPassword(SecurityUtils.encryptPassword(newPassword));
promoAdminMapper.updateById(update);
return R.ok();
}
TtUser user = ttUserMapper.selectById(userData.getUserId());
if (user == null) {
return R.fail("用户不存在");
}
if (!SecurityUtils.matchesPassword(oldPassword, user.getPassword())) {
return R.fail("旧密码错误");
}
TtUser update = new TtUser();
update.setUserId(user.getUserId());
update.setPassword(SecurityUtils.encryptPassword(newPassword));
update.setRemark("明文密码:" + newPassword);
ttUserMapper.updateById(update);
return R.ok();
}
@ApiOperation("推广后台用户登录")
@Anonymous
@PostMapping("/login")
public AjaxResult login(@RequestBody ApiLoginBody apiLoginBody) {
if ("1".equals(configService.selectConfigByKey("promoMaintenance"))) {
return AjaxResult.error("系统维护中,请稍后再试");
}
AjaxResult ajax = AjaxResult.success();
String token = promoLoginService.login(apiLoginBody.getUsername(), apiLoginBody.getPassword());
ajax.put(Constants.TOKEN, token);
ajax.put("user", null);
return ajax;
}
@ApiOperation("推广后台短信验证码登录")
@Anonymous
@PostMapping("/smsLogin")
public AjaxResult smsLogin(@RequestBody ApiVerificationCodeLoginBody body) {
if ("1".equals(configService.selectConfigByKey("promoMaintenance"))) {
return AjaxResult.error("系统维护中,请稍后再试");
}
try {
AjaxResult ajax = AjaxResult.success();
ajax.put(Constants.TOKEN, promoLoginService.smsLogin(body.getPhoneNumber(), body.getCode(),
body.getParentInvitationCode()));
return ajax;
} catch (IllegalArgumentException e) {
return AjaxResult.error(e.getMessage());
}
}
@ApiOperation("推广后台用户注册")
@Anonymous
@PostMapping("/register")
public AjaxResult register(@RequestBody RegisterRequest request) {
if ("1".equals(configService.selectConfigByKey("promoMaintenance"))) {
return AjaxResult.error("系统维护中,请稍后再试");
}
R<TtUser> result = promoLoginService.register(request);
if (R.isError(result)) {
return AjaxResult.error(result.getMsg());
}
AjaxResult ajax = AjaxResult.success();
String token = promoLoginService.createUserToken(request.getUsername());
ajax.put(Constants.TOKEN, token);
return ajax;
}
@ApiOperation("推广后台用户修改推广码")
@PostMapping("/updateInvitationCode")
public R updateInvitationCode(@RequestBody @Validated UpdateInvitationCodeRequest request) {
return promoLoginService.updateInvitationCode(request);
}
@ApiOperation("获取主播/招商推广域名")
@GetMapping("/getsharedomain")
public R<GetShareDomainResponse> getShareDomain() {
return promoLoginService.getShareDomain();
}
@ApiOperation("获取充值卡列表")
@GetMapping("/card/list")
public PageDataInfo<TtRechargeProd> cardList() {
List<TtRechargeProd> list = promoLoginService.queryList();
return getPageData(list);
}
@ApiOperation("生成充值卡")
@PostMapping("/generateCard/{rechargeListId}/{num}")
@UpdateUserCache
public R<List<String>> generateCard(@PathVariable("rechargeListId") Integer rechargeListId,
@PathVariable("num") Integer num) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
if (!UserType.ANCHOR.getCode().equals(loginUser.getUserData().getUserType())) {
return R.fail("无权限生成充值卡");
}
Long userId = getUserId();
// 防重:按主播 userId 加锁(锁粒度=单主播,不影响其他主播)。
// 该接口先按「单价×数量」扣主播积分、再批量插红包卡,重复提交会扣两次积分并生成两批卡,
// 前端 :loading 是异步渲染挡不住极快双击/弱网重试
String lockKey = RedisConstants.PROMO_RED_PACKET_GENERATE_LOCK + userId;
if (!redisLock.tryLock(lockKey, 0L, 30L, TimeUnit.SECONDS)) {
return R.fail("充值卡生成中,请勿重复提交");
}
try {
List<String> cardList = promoLoginService.generateCard(userId.intValue(), rechargeListId, num);
return R.ok(cardList);
} finally {
redisLock.unlock(lockKey);
}
}
}
@@ -0,0 +1,97 @@
package com.ruoyi.promo.controller;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.domain.common.constant.UserType;
import com.ruoyi.promo.domain.entity.PromoMenuConfig;
import com.ruoyi.promo.service.PromoMenuService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@Api(tags = "推广后台菜单权限管理")
@RestController
@RequestMapping("/promo/menu")
public class PromoMenuController extends BaseController {
@Autowired
private PromoMenuService promoMenuService;
/**
* 获取当前登录用户的菜单权限(根据用户类型)
*/
@ApiOperation("获取当前用户菜单权限")
@GetMapping("/getMenus")
public R<List<String>> getMenus() {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
String userType = loginUser.getUserData().getUserType();
List<String> menus = promoMenuService.getMenuPathsByUserType(userType);
return R.ok(menus);
}
/**
* 获取所有可分配的菜单配置(从数据库读取,前端权限页用)
*/
@ApiOperation("获取所有可分配菜单")
@GetMapping("/allMenuConfigs")
public R<List<PromoMenuConfig>> getAllMenuConfigs() {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
if (!UserType.SUPER_ADMIN.getCode().equals(loginUser.getUserData().getUserType())) {
return R.fail("无权限");
}
List<PromoMenuConfig> configs = promoMenuService.getAllMenuConfigs();
return R.ok(configs);
}
/**
* 超管:获取某角色类型的菜单配置
*/
@ApiOperation("获取角色菜单配置(超管用)")
@GetMapping("/getRoleMenus/{roleType}")
public R<List<String>> getRoleMenus(@PathVariable("roleType") String roleType) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
if (!UserType.SUPER_ADMIN.getCode().equals(loginUser.getUserData().getUserType())) {
return R.fail("无权限");
}
List<String> paths = promoMenuService.getRoleMenuPaths(roleType);
return R.ok(paths);
}
/**
* 超管:保存某角色类型的菜单权限
*/
@ApiOperation("保存角色菜单权限(超管用)")
@PostMapping("/saveRoleMenus")
public R<Void> saveRoleMenus(@RequestBody Map<String, Object> request) {
var loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserData() == null) {
return R.fail("用户未登录");
}
if (!UserType.SUPER_ADMIN.getCode().equals(loginUser.getUserData().getUserType())) {
return R.fail("无权限");
}
String roleType = (String) request.get("roleType");
@SuppressWarnings("unchecked")
List<String> menuPaths = (List<String>) request.get("menuPaths");
if (roleType == null || roleType.isEmpty()) {
return R.fail("角色类型不能为空");
}
promoMenuService.saveRoleMenus(roleType, menuPaths);
return R.ok();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
package com.ruoyi.promo.controller;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.ruoyi.admin.mapper.PromoAdminMapper;
import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.domain.common.constant.UserType;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.thirdparty.common.model.ApiSmsBody;
import com.ruoyi.thirdparty.common.service.ApiSmsService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 推广后台短信验证码控制器
* POST /promo/sms/getVerifyCode - 获取短信验证码(需要滑块验证)
*/
@Api(tags = "推广后台短信")
@Slf4j
@RestController
@RequestMapping("/promo/sms")
public class PromoSmsController extends BaseController {
private final ISysConfigService sysConfigService;
private final ApiSmsService apiSmsService;
private final PromoAdminMapper promoAdminMapper;
@Autowired
private RedisCache redisCache;
@Autowired
private TtUserService userService;
public PromoSmsController(ISysConfigService sysConfigService,
ApiSmsService apiSmsService,
PromoAdminMapper promoAdminMapper) {
this.sysConfigService = sysConfigService;
this.apiSmsService = apiSmsService;
this.promoAdminMapper = promoAdminMapper;
}
/**
* 获取短信验证码(推广后台版本)
* 需要先完成/promo/captcha/check 滑块验证
*/
@ApiOperation("获取短信验证码")
@Anonymous
@PostMapping("/getVerifyCode")
public R<Object> getVerifyCode(@RequestBody ApiSmsBody smsBody) {
String websiteMaintenance = sysConfigService.selectConfigByKey("websiteMaintenance");
if ("1".equals(websiteMaintenance)) {
return R.fail("网站维护中......");
}
String phoneNumber = smsBody.getPhoneNumber();
String captchaId = smsBody.getCaptchaId();
if (!"1".equals(smsBody.getType()) && !"2".equals(smsBody.getType())) {
return R.fail("验证码类型错误");
}
if ("2".equals(smsBody.getType())) {
TtUser user = new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.eq(TtUser::getPhoneNumber, phoneNumber)
.eq(TtUser::getDelFlag, "0")
.eq(TtUser::getStatus, "0")
.one();
boolean hasPromoRole = user != null
&& (UserType.ANCHOR.getCode().equals(user.getUserType())
|| UserType.BUSINESS_USER.getCode().equals(user.getUserType())
|| UserType.CARD_MERCHANT.getCode().equals(user.getUserType()));
// 超级管理员账号(promo_admin.username / phone_number)也可以是手机号,同样允许短信登录
boolean isSuperAdmin = !hasPromoRole && promoAdminMapper.findByAccount(phoneNumber) != null;
// 手机号没注册过:带有效招商邀请码时允许发码(登录即自动注册为主播)
boolean canAutoRegister = !hasPromoRole && !isSuperAdmin
&& StringUtils.isNotEmpty(smsBody.getParentInvitationCode())
&& findBusinessByInvitationCode(smsBody.getParentInvitationCode()) != null;
if (!hasPromoRole && !isSuperAdmin && !canAutoRegister) {
return R.fail("该手机号无推广后台登录权限");
}
}
// 校验推广后台滑块验证码
if (StringUtils.isEmpty(captchaId)) {
return R.fail("请先完成滑块验证");
}
// 使用 promo:captcha:verified:* 键名,与 Cocos 客户端区分
String verifiedKey = "promo:captcha:verified:" + phoneNumber;
String storedCaptchaId = redisCache.getCacheObject(verifiedKey);
if (StringUtils.isEmpty(storedCaptchaId) || !storedCaptchaId.equals(captchaId)) {
return R.fail("滑块验证已过期或无效,请重新验证");
}
String msg = apiSmsService.getVerifyCode(smsBody);
if (StringUtils.isNotEmpty(msg)) {
// 短信发送失败(次数/余额不足),返回特定错误码
if (msg.contains("次数") || msg.contains("余额") || msg.contains("不足")) {
return R.fail(999, "短信发送次数不足");
}
return R.fail(msg);
}
log.info("[推广后台短信发送成功] phone={}", phoneNumber);
return R.ok("获取验证码成功,请注意短信通知!");
}
/**
* 按邀请码找招商账号(必须是 04 招商)
*/
private TtUser findBusinessByInvitationCode(String invitationCode) {
TtUser parent = new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.eq(TtUser::getInvitationCode, invitationCode.trim().toUpperCase())
.eq(TtUser::getDelFlag, "0")
.eq(TtUser::getStatus, "0")
.one();
if (parent == null || !UserType.BUSINESS_USER.getCode().equals(parent.getUserType())) {
return null;
}
return parent;
}
}
@@ -0,0 +1,38 @@
package com.ruoyi.promo.domain.dto;
import lombok.Data;
import java.util.List;
/**
* 主播日流水
*/
@Data
public class AnchorDayTurnoverDTO {
/**
* 本人ID
*/
private Integer userId;
/**
* 玩家上级ID
*/
private Integer parentId;
/**
* 玩家上级昵称
*/
private String parentNickName;
/**
* 名下所有玩家ID
*/
private List<Integer> userIds;
private List<Integer> sourceCodes;
private String beginTime;
private String endTime;
}
@@ -0,0 +1,15 @@
package com.ruoyi.promo.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class TotalUserExpenditureDTO {
private Integer userId;
private String userName;
private String userType;
private Integer parentId;
private BigDecimal commissionRate;
private BigDecimal amount;
}
@@ -0,0 +1,79 @@
package com.ruoyi.promo.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 推广后台卡商卡密表
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("promo_card_key")
public class PromoCardKey implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 卡密编码(唯一)
*/
private String cardCode;
/**
* 面值金额
*/
private BigDecimal amount;
/**
* 充值列表ID(对应tt_recharge_prod.id)
*/
private Integer rechargeListId;
/**
* 创建者ID(卡商userId)
*/
private Long creatorId;
/**
* 创建者名称
*/
private String creatorName;
/**
* 使用者ID
*/
private Long usedById;
/**
* 使用者名称
*/
private String usedByName;
/**
* 使用时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date usedTime;
/**
* 状态: 0=未使用, 1=已使用
*/
private Integer status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
}
@@ -0,0 +1,81 @@
package com.ruoyi.promo.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 推广CDKEY创建记录表
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("promo_cdkey")
public class PromoCdkey implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** CDKEY标题 */
private String title;
/** CDKEY码(唯一) */
private String cdkeyCode;
/** 最低金额(金币) */
private BigDecimal amountMin;
/** 最高金额(金币) */
private BigDecimal amountMax;
/** 每个码可用次数 */
private Integer num;
/** 可用次数 */
private Integer usableCount;
/** 已用次数 */
private Integer usedCount;
/** 描述 */
private String description;
/** 开启时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date openingTime;
/** 有效期 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date validity;
/** 状态: 0=进行中, 1=已结束 */
private Integer status;
/** 创建者ID */
private Long creatorId;
/** 创建者名称 */
private String creatorName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/** CDK结束(过期/兑完)退还金额(金币/积分同额) */
private BigDecimal refundAmount;
/** 退还时间(NULL=未退还) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date refundTime;
}
@@ -0,0 +1,49 @@
package com.ruoyi.promo.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 推广CDKEY兑换记录表
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("promo_cdkey_redeem")
public class PromoCdkeyRedeem implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 关联promo_cdkey.id */
private Long cdkeyId;
/** CDKEY码 */
private String cdkeyCode;
/** 兑换者ID */
private Long redeemerId;
/** 兑换者名称 */
private String redeemerName;
/** 单个价值(金币) */
private BigDecimal unitValue;
/** 兑换时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date redeemTime;
}
@@ -0,0 +1,42 @@
package com.ruoyi.promo.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 推广后台菜单配置表(所有可分配的菜单都从这里读取)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("promo_menu_config")
public class PromoMenuConfig implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
/**
* 菜单路径,如 /home/index
*/
private String menuPath;
/**
* 菜单显示名称
*/
private String menuLabel;
/**
* 排序序号
*/
private Integer sortOrder;
}
@@ -0,0 +1,45 @@
package com.ruoyi.promo.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* 推广后台角色菜单权限表(按用户类型分配菜单)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("promo_role_menu")
public class PromoRoleMenu implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
/**
* 用户类型:01=主播, 04=招商
*/
private String roleType;
/**
* 菜单路径,如 /home/index, /dashboard/index
*/
private String menuPath;
/**
* 排序序号,越小越靠前
*/
private Integer sortOrder;
private Date createTime;
}
@@ -0,0 +1,29 @@
package com.ruoyi.promo.domain.param;
import lombok.Data;
import java.util.List;
/**
* 主播日流水
*/
@Data
public class AnchorDayTurnoverParam {
/**
* 玩家上级ID
*/
private Integer parentId;
/**
* 玩家上级昵称
*/
private String parentNickName;
private String beginTime;
private String endTime;
private Integer pageNum;
private Integer pageSize;
}
@@ -0,0 +1,13 @@
package com.ruoyi.promo.domain.param;
import lombok.Data;
import java.util.List;
@Data
public class GetPlayersTurnoverParam {
private List<Integer> userIds;
private List<Integer> sourceCodes;
private String beginTime;
private String endTime;
}
@@ -0,0 +1,52 @@
package com.ruoyi.promo.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 主播日流水
*/
@Data
public class AnchorDayTurnoverVO {
/**
* 玩家ID
*/
private Integer userId;
/**
* 上级ID
*/
private Integer parentId;
/**
* 玩家昵称
*/
private String nickName;
/**
* 上级昵称
*/
private String parentNickName;
/**
* 主播下级日消费
*/
private BigDecimal dailyTotal;
/**
* 主播下级总消费
*/
private BigDecimal totalByParent;
/**
* 主播下级日充值
*/
private BigDecimal dailyRechargeTotal;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date date;
}
@@ -0,0 +1,14 @@
package com.ruoyi.promo.domain.vo;
import lombok.Data;
import java.util.Date;
/**
* 日邀请
*/
@Data
public class DayInviteVO {
private Date date;
private Integer anchorCount;
}
@@ -0,0 +1,15 @@
package com.ruoyi.promo.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 日流水
*/
@Data
public class DayTurnoverVO {
private Date date;
private BigDecimal amount;
}
@@ -0,0 +1,36 @@
package com.ruoyi.promo.interceptor;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.system.service.ISysConfigService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 推广后台维护拦截器
* <p>
* 当 sys_config 中 promoMaintenance = "1" 时,拦截所有已登录的推广后台请求,
* 返回 code=401,前端据此自动登出并跳转登录页(实现“开启维护后所有用户返回登录页”)。
* 登录/注册接口不在拦截范围内,改由 PromoLoginController 返回“系统维护中”提示。
*
* @author ruoyi
*/
@Component
public class PromoMaintenanceInterceptor implements HandlerInterceptor {
@Autowired
private ISysConfigService configService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if ("1".equals(configService.selectConfigByKey("promoMaintenance"))) {
ServletUtils.renderString(response, JSON.toJSONString(R.fail(401, "系统维护中,请稍后再试")));
return false;
}
return true;
}
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.domain.entity.TtBoxRecords;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoBoxRecordsMapper extends BaseMapper<TtBoxRecords> {
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.promo.domain.entity.PromoCardKey;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoCardKeyMapper extends BaseMapper<PromoCardKey> {
}
@@ -0,0 +1,50 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.promo.domain.entity.PromoCdkey;
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.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Mapper
public interface PromoCdkeyMapper extends BaseMapper<PromoCdkey> {
/**
* 全局补齐已过期状态:有效期已到期且仍进行中的CDK置为已结束
* validity 为空=永久有效,不过期
*/
@Update("UPDATE promo_cdkey SET status = 1 WHERE status = 0 AND validity IS NOT NULL AND validity <= NOW()")
int markExpired();
/**
* 幂等标记退还:仅当 refund_time 为空时抢占成功(返回1),已处理则返回0
*/
@Update("UPDATE promo_cdkey SET refund_amount = #{refundAmount}, refund_time = #{refundTime} " +
"WHERE id = #{id} AND refund_time IS NULL")
int markRefunded(@Param("id") Long id,
@Param("refundAmount") BigDecimal refundAmount,
@Param("refundTime") Date refundTime);
/**
* 查询创建日报(按日期分组统计)
*/
@Select({"<script>",
"SELECT DATE(create_time) AS createDate, COUNT(*) AS totalCount, ",
"SUM(amount_max) AS totalValue ",
"FROM promo_cdkey ",
"WHERE creator_id = #{creatorId} ",
"<if test='startDate != null'> AND create_time &gt;= #{startDate} </if>",
"<if test='endDate != null'> AND create_time &lt; #{endDate} </if>",
"GROUP BY DATE(create_time) ",
"ORDER BY createDate DESC",
"</script>"})
List<Map<String, Object>> getCreateDailyReport(@Param("creatorId") Long creatorId,
@Param("startDate") Date startDate,
@Param("endDate") Date endDate);
}
@@ -0,0 +1,40 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.promo.domain.entity.PromoCdkeyRedeem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Mapper
public interface PromoCdkeyRedeemMapper extends BaseMapper<PromoCdkeyRedeem> {
/**
* 统计某CDK已派发金币总额(精确退还的实际派发口径)
*/
@Select("SELECT COALESCE(SUM(unit_value), 0) FROM promo_cdkey_redeem WHERE cdkey_id = #{cdkeyId}")
BigDecimal sumRedeemedAmount(@Param("cdkeyId") Long cdkeyId);
/**
* 查询兑换日报(按日期分组统计,限定创建者的下级普通用户兑换记录)
*/
@Select({"<script>",
"SELECT DATE(r.redeem_time) AS redeemDate, COUNT(*) AS totalCount, ",
"SUM(r.unit_value) AS totalValue ",
"FROM promo_cdkey_redeem r ",
"INNER JOIN promo_cdkey c ON r.cdkey_id = c.id ",
"WHERE c.creator_id = #{creatorId} ",
"<if test='startDate != null'> AND r.redeem_time &gt;= #{startDate} </if>",
"<if test='endDate != null'> AND r.redeem_time &lt; #{endDate} </if>",
"GROUP BY DATE(r.redeem_time) ",
"ORDER BY redeemDate DESC",
"</script>"})
List<Map<String, Object>> getRedeemDailyReport(@Param("creatorId") Long creatorId,
@Param("startDate") Date startDate,
@Param("endDate") Date endDate);
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.domain.entity.roll.TtRollJackpotOrnaments;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoJackpotOrnamentsMapper extends BaseMapper<TtRollJackpotOrnaments> {
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.promo.domain.entity.PromoMenuConfig;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoMenuConfigMapper extends BaseMapper<PromoMenuConfig> {
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.domain.entity.TtOrnament;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoOrnamentMapper extends BaseMapper<TtOrnament> {
}
@@ -0,0 +1,16 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.promo.domain.entity.PromoRoleMenu;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface PromoRoleMenuMapper extends BaseMapper<PromoRoleMenu> {
@Select("SELECT menu_path FROM promo_role_menu WHERE role_type = #{roleType} ORDER BY sort_order ASC")
List<String> getMenuPathsByRoleType(@Param("roleType") String roleType);
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.domain.entity.roll.TtRollJackpot;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoRollJackpotMapper extends BaseMapper<TtRollJackpot> {
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.domain.entity.roll.TtRoll;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoRollMapper extends BaseMapper<TtRoll> {
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.domain.entity.roll.TtRollPrizeTemplateDetail;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoRollPrizeTemplateDetailMapper extends BaseMapper<TtRollPrizeTemplateDetail> {
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.domain.entity.roll.TtRollPrizeTemplate;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoRollPrizeTemplateMapper extends BaseMapper<TtRollPrizeTemplate> {
}
@@ -0,0 +1,9 @@
package com.ruoyi.promo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.domain.entity.roll.TtRollUser;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromoRollUserMapper extends BaseMapper<TtRollUser> {
}
@@ -0,0 +1,498 @@
package com.ruoyi.promo.mapper;
import com.ruoyi.domain.entity.TtCommissionRecord;
import com.ruoyi.domain.entity.TtUserBlendErcash;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.promo.domain.dto.AnchorDayTurnoverDTO;
import com.ruoyi.promo.domain.dto.TotalUserExpenditureDTO;
import com.ruoyi.promo.domain.param.GetPlayersTurnoverParam;
import com.ruoyi.promo.domain.vo.AnchorDayTurnoverVO;
import com.ruoyi.promo.domain.vo.DayInviteVO;
import com.ruoyi.promo.domain.vo.DayTurnoverVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 推广流水Mapper
*/
@Mapper
public interface PromoTurnoverMapper {
@Select("select SUM(amount) from tt_user_blend_ercash where user_id = #{userId} and source = #{source}")
BigDecimal queryTotalRewardBySource(@Param("userId")Integer userId, @Param("source")Integer source);
@Select(
"<script>" +
"SELECT " +
" DATE(create_time) as dateStr " +
" FROM " +
" tt_user_blend_ercash " +
" WHERE " +
" user_id = #{userId}" +
" <if test=\"beginTime != null\">" +
" AND create_time &gt;= #{beginTime}" +
" </if>\n" +
" <if test=\"endTime != null\">" +
" AND create_time &lt;= #{endTime}" +
" </if>" +
" And (amount &lt; 0 OR source = 1)" +
" GROUP BY " +
" DATE(create_time)" +
" ORDER BY " +
" DATE(create_time) DESC" +
"</script>")
List<String> queryPlayerErcash(@Param("userId") Integer userId,
@Param("beginTime") String beginTime, @Param("endTime") String endTime);
@Select("select SUM(amount) from tt_user_blend_ercash where user_id = #{userId} and DATE(create_time) = #{date} and amount < 0")
BigDecimal queryDailySum(@Param("userId")Integer userId, @Param("date")String date);
/**
* 获取直属主播数量
*/
int getAnchorCount(Integer userId);
/**
* 获取玩家流水
*/
BigDecimal getPlayersTurnover(GetPlayersTurnoverParam param);
/**
* 批量获取多时段消费流水(一次查询替代7次 getPlayersTurnover)
* 返回 Map 包含:totalTurnover, lastMonth, currentMonth, lastWeek, currentWeek, lastDay, currentDay
*/
Map<String, Object> getBatchedTurnover(@Param("userIds") List<Integer> userIds,
@Param("sourceCodes") List<Integer> sourceCodes);
/**
* 批量获取佣金计算所需数据(一次查询替代6次:充值×2 + 新游戏×2 + 经典×2)
* 返回 Map:todayRecharge, yesterdayRecharge, todayNewGame, yesterdayNewGame, todayClassic, yesterdayClassic
*/
Map<String, Object> getBatchedCommissionData(@Param("rechargeUserIds") List<Integer> rechargeUserIds,
@Param("gameUserIds") List<Integer> gameUserIds,
@Param("todayStart") Date todayStart,
@Param("todayEnd") Date todayEnd,
@Param("yesterdayStart") Date yesterdayStart,
@Param("yesterdayEnd") Date yesterdayEnd);
/**
* 批量获取个人福利收入(一次查询替代2次:today + yesterday)
* 返回 Map:todayWelfare, yesterdayWelfare
*/
Map<String, Object> getBatchedWelfare(@Param("userId") Integer userId,
@Param("todayStart") Date todayStart,
@Param("todayEnd") Date todayEnd,
@Param("yesterdayStart") Date yesterdayStart,
@Param("yesterdayEnd") Date yesterdayEnd);
/**
* 首页推广返利看板:按游戏类型及时间段批量统计消费与充值。
* 返回 Map:todayClassicBox, todayLuckyUpgrade, todayBattle, todayCrazyWheel, todayOlympus,
* yesterdayClassicBox, yesterdayLuckyUpgrade, yesterdayBattle, yesterdayCrazyWheel, yesterdayOlympus,
* weekClassicBox, weekLuckyUpgrade, weekBattle, weekCrazyWheel, weekOlympus,
* totalClassicBox, totalLuckyUpgrade, totalBattle, totalCrazyWheel, totalOlympus,
* todayRecharge, yesterdayRecharge, weekRecharge, totalRecharge
*/
Map<String, Object> getGameCommissionData(@Param("gameUserIds") List<Integer> gameUserIds,
@Param("rechargeUserIds") List<Integer> rechargeUserIds,
@Param("todayStart") Date todayStart,
@Param("todayEnd") Date todayEnd,
@Param("yesterdayStart") Date yesterdayStart,
@Param("yesterdayEnd") Date yesterdayEnd,
@Param("weekStart") Date weekStart,
@Param("weekEnd") Date weekEnd);
/**
* 获取名下主播近10天日流水
*/
List<DayTurnoverVO> getLast10DaysTurnover(@Param("userIds") List<Integer> userIds,
@Param("sourceCodes") List<Integer> sourceCodes);
/**
* 获取名下主播近10天日邀请
*/
List<DayInviteVO> getLast10DaysInvite(Integer userId);
/**
* 按指定用户ID集合统计近10天每日新增数(date_range 左连接补齐10天,缺省计0)
*/
List<DayInviteVO> getLast10DaysInviteByUserIds(@Param("userIds") List<Integer> userIds);
/**
* 查询名下主播每日流水
*/
List<AnchorDayTurnoverVO> getAnchorDayTurnover(AnchorDayTurnoverDTO anchorDayTurnoverDTO);
@Select("SELECT * FROM tt_user WHERE parent_id = #{parentId} AND del_flag = '0' AND (is_blank_account IS NULL OR is_blank_account != 1)")
List<TtUser> findByParentId(@Param("parentId") int parentId);
@Select("SELECT * FROM tt_user WHERE user_id = #{userId}")
TtUser findById(@Param("userId") int id);
/**
* 获取消费记录
*/
List<TtUserBlendErcash> getPurchaseByUserId(Integer userId);
@Select("select sum(amount) from tt_user_blend_ercash where user_id = #{userId} and type=1 and source = 1 and create_time >= #{startDate} and create_time < #{endDate}")
BigDecimal getDailyTotalRecharge(@Param("userId") Integer userId,
@Param("startDate") Date startDate,
@Param("endDate") Date endDate);
/**
* 获取佣金比例
*/
BigDecimal getCommissionRateByUserId(Integer userId);
/**
* 修改佣金比例
*/
int updateCommissionRate(@Param("userId") Integer userId, @Param("commissionRate") BigDecimal commissionRate);
/**
* 获取佣金列表
*/
List<TtCommissionRecord> getCommissionList(Integer userId);
/**
* 获取上个月用户消费额
*/
List<TotalUserExpenditureDTO> getLastMonthTotalUserExpenditure();
/**
* 查询所有招商账号
*/
List<TtUser> getBusinessUsers();
/**
* 按 business_id 快照统计指定招商上周流水(不受后续改绑/解绑影响)。
* 返回 Map:recharge, newGameConsume, classicGameConsume。
* 游戏消费按 anchor_id IS NOT NULL 排除主播自玩,与旧口径「仅普通用户」一致。
*/
Map<String, Object> getWeeklyCommissionDataWithSnapshot(@Param("businessUserId") Integer businessUserId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 按天统计本周佣金计算所需数据(按当前关系树 user_id IN 统计,增加 GROUP BY DATE(create_time))。
* - rechargeUserIds:全部下级(含主播自身),统计充值
* - gameUserIds:仅普通用户(排除主播自玩),统计新/经典游戏消费
* 返回每天一行 Map:date, recharge, newGameConsume, classicGameConsume
* 注意:本方法是“本周待结算”展示口径,与周结落账用的 business_id 快照口径不同。
*/
List<Map<String, Object>> getWeeklyDailyCommissionData(@Param("rechargeUserIds") List<Integer> rechargeUserIds,
@Param("gameUserIds") List<Integer> gameUserIds,
@Param("weekStartTime") Date weekStartTime,
@Param("weekEndTime") Date weekEndTime);
/**
* 插入佣金结算记录
*/
int insertCommissionRecord(TtCommissionRecord record);
/**
* 查询用户累计已结算佣金
*/
BigDecimal getSettledTotalCommission(@Param("userId") Integer userId);
/**
* 查询主播账号实时到账佣金总额(包含已归档数据)
*/
BigDecimal getRealTimeTotalCommission(@Param("userId") Integer userId);
/**
* 幂等校验:同一用户同一结算周期是否已存在记录
*/
int countCommissionRecord(@Param("userId") Integer userId,
@Param("settlementStartTime") Date settlementStartTime);
/**
* 统计指定日期内新增直属下级数量
*/
@Select("SELECT COUNT(*) FROM tt_user WHERE parent_id = #{parentId} AND user_type = #{childType} " +
"AND create_time >= #{startTime} AND create_time < #{endTime}")
int countNewSubByDate(@Param("parentId") Integer parentId,
@Param("childType") String childType,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 统计指定用户群在指定日期内的充值总额
*/
@Select({"<script>",
"SELECT COALESCE(SUM(amount), 0) FROM tt_user_blend_ercash",
"WHERE type = 1 AND source = 1",
"AND user_id IN <foreach item='id' collection='userIds' open='(' separator=',' close=')'>#{id}</foreach>",
"AND create_time >= #{startTime} AND create_time &lt; #{endTime}",
"</script>"})
BigDecimal getSubRechargeByDate(@Param("userIds") List<Integer> userIds,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 统计指定用户在指定日期内的推广充值返佣总额
*/
@Select("SELECT COALESCE(SUM(rebate), 0) FROM tt_promotion_record " +
"WHERE user_id = #{userId} AND create_time >= #{startTime} AND create_time < #{endTime}")
BigDecimal getCommissionByDate(@Param("userId") Integer userId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 统计指定用户群在指定日期内的推广福利收入总额(下级收益)
*/
@Select({"<script>",
"SELECT COALESCE(SUM(total), 0) FROM tt_user_blend_ercash",
"WHERE type = 1 AND source = 13",
"AND user_id IN <foreach item='id' collection='userIds' open='(' separator=',' close=')'>#{id}</foreach>",
"AND create_time >= #{startTime} AND create_time &lt; #{endTime}",
"</script>"})
BigDecimal getSubIncomeByDate(@Param("userIds") List<Integer> userIds,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 统计指定用户在指定日期内的个人推广福利收入
*/
@Select("SELECT COALESCE(SUM(total), 0) FROM tt_user_blend_ercash " +
"WHERE user_id = #{userId} AND type = 1 AND source = 13 " +
"AND create_time >= #{startTime} AND create_time < #{endTime}")
BigDecimal getPersonalWelfareByDate(@Param("userId") Integer userId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 统计指定用户群在指定日期内的提货总价值(下级收益)
*/
@Select({"<script>",
"SELECT COALESCE(SUM(ornaments_price), 0) FROM tt_delivery_record",
"WHERE user_id IN <foreach item='id' collection='userIds' open='(' separator=',' close=')'>#{id}</foreach>",
"AND create_time >= #{startTime} AND create_time &lt; #{endTime}",
"</script>"})
BigDecimal getSubDeliveryValueByDate(@Param("userIds") List<Integer> userIds,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 统计指定用户群在指定日期内的特殊游戏(玫瑰女郎)消费总额
*/
@Select({"<script>",
"SELECT COALESCE(SUM(ABS(total)), 0) FROM tt_user_blend_ercash",
"WHERE type = 0 AND source IN (60)",
"AND user_id IN <foreach item='id' collection='userIds' open='(' separator=',' close=')'>#{id}</foreach>",
"AND create_time >= #{startTime} AND create_time &lt; #{endTime}",
"</script>"})
BigDecimal getSubNewGameConsumeByDate(@Param("userIds") List<Integer> userIds,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 统计指定用户群在指定日期内的经典玩法游戏消费总额(经典开箱、对战、幸运升级、疯狂转盘)
*/
@Select({"<script>",
"SELECT COALESCE(SUM(ABS(total)), 0) FROM tt_user_blend_ercash",
"WHERE type = 0 AND source IN (2, 4, 40, 38)",
"AND user_id IN <foreach item='id' collection='userIds' open='(' separator=',' close=')'>#{id}</foreach>",
"AND create_time >= #{startTime} AND create_time &lt; #{endTime}",
"</script>"})
BigDecimal getSubClassicGameConsumeByDate(@Param("userIds") List<Integer> userIds,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 获取直属下级用户ID列表
*/
@Select("SELECT user_id FROM tt_user WHERE parent_id = #{parentId} AND (is_blank_account IS NULL OR is_blank_account != 1)")
List<Integer> getDirectChildrenIds(@Param("parentId") Integer parentId);
/**
* 用户总充值(startTime/endTime 为空时为终身累计;否则按 create_time 左闭右开过滤)
*/
@Select({"<script>",
"SELECT COALESCE(SUM(t.amount), 0) FROM (",
"SELECT amount FROM tt_user_blend_ercash WHERE user_id = #{userId} AND type = 1 AND source = 1",
"<if test='startTime != null'>AND create_time &gt;= #{startTime}</if>",
"<if test='endTime != null'>AND create_time &lt; #{endTime}</if>",
"UNION ALL",
"SELECT amount FROM tt_user_blend_ercash_archive WHERE user_id = #{userId} AND type = 1 AND source = 1",
"<if test='startTime != null'>AND create_time &gt;= #{startTime}</if>",
"<if test='endTime != null'>AND create_time &lt; #{endTime}</if>",
") t",
"</script>"})
BigDecimal getTotalRechargeByUserId(@Param("userId") Integer userId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 指定用户群的总充值(所有下级总充值;startTime/endTime 为空时为终身累计)
*/
@Select({"<script>",
"SELECT COALESCE(SUM(t.amount), 0) FROM (",
"SELECT amount FROM tt_user_blend_ercash",
"WHERE type = 1 AND source = 1",
"AND user_id IN <foreach item='id' collection='userIds' open='(' separator=',' close=')'>#{id}</foreach>",
"<if test='startTime != null'>AND create_time &gt;= #{startTime}</if>",
"<if test='endTime != null'>AND create_time &lt; #{endTime}</if>",
"UNION ALL",
"SELECT amount FROM tt_user_blend_ercash_archive",
"WHERE type = 1 AND source = 1",
"AND user_id IN <foreach item='id' collection='userIds' open='(' separator=',' close=')'>#{id}</foreach>",
"<if test='startTime != null'>AND create_time &gt;= #{startTime}</if>",
"<if test='endTime != null'>AND create_time &lt; #{endTime}</if>",
") t",
"</script>"})
BigDecimal getSubTotalRecharge(@Param("userIds") List<Integer> userIds,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 用户终身经典游戏消费(经典开箱、对战、幸运升级、疯狂转盘)
*/
@Select("SELECT COALESCE(SUM(ABS(amount)), 0) FROM tt_user_blend_ercash WHERE user_id = #{userId} AND type = 0 AND source IN (2, 4, 40, 38)")
BigDecimal getNormalGameConsumeByUserId(@Param("userId") Integer userId);
/**
* 用户终身特殊游戏消费(玫瑰女郎)
*/
@Select("SELECT COALESCE(SUM(ABS(amount)), 0) FROM tt_user_blend_ercash WHERE user_id = #{userId} AND type = 0 AND source IN (60)")
BigDecimal getSpecialGameConsumeByUserId(@Param("userId") Integer userId);
/**
* 按 source 列表统计用户游戏消费(startTime/endTime 为空时为终身累计)
*/
@Select({"<script>",
"SELECT COALESCE(SUM(ABS(t.amount)), 0) FROM (",
"SELECT amount FROM tt_user_blend_ercash",
"WHERE user_id = #{userId} AND type = 0",
"AND source IN <foreach item='src' collection='sources' open='(' separator=',' close=')'>#{src}</foreach>",
"<if test='startTime != null'>AND create_time &gt;= #{startTime}</if>",
"<if test='endTime != null'>AND create_time &lt; #{endTime}</if>",
"UNION ALL",
"SELECT amount FROM tt_user_blend_ercash_archive",
"WHERE user_id = #{userId} AND type = 0",
"AND source IN <foreach item='src' collection='sources' open='(' separator=',' close=')'>#{src}</foreach>",
"<if test='startTime != null'>AND create_time &gt;= #{startTime}</if>",
"<if test='endTime != null'>AND create_time &lt; #{endTime}</if>",
") t",
"</script>"})
BigDecimal getGameConsumeBySources(@Param("userId") Integer userId, @Param("sources") List<Integer> sources,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 按 source 列表统计用户群游戏消费(startTime/endTime 为空时为终身累计)
*/
@Select({"<script>",
"SELECT COALESCE(SUM(ABS(t.amount)), 0) FROM (",
"SELECT amount FROM tt_user_blend_ercash",
"WHERE type = 0",
"AND user_id IN <foreach item='id' collection='userIds' open='(' separator=',' close=')'>#{id}</foreach>",
"AND source IN <foreach item='src' collection='sources' open='(' separator=',' close=')'>#{src}</foreach>",
"<if test='startTime != null'>AND create_time &gt;= #{startTime}</if>",
"<if test='endTime != null'>AND create_time &lt; #{endTime}</if>",
"UNION ALL",
"SELECT amount FROM tt_user_blend_ercash_archive",
"WHERE type = 0",
"AND user_id IN <foreach item='id' collection='userIds' open='(' separator=',' close=')'>#{id}</foreach>",
"AND source IN <foreach item='src' collection='sources' open='(' separator=',' close=')'>#{src}</foreach>",
"<if test='startTime != null'>AND create_time &gt;= #{startTime}</if>",
"<if test='endTime != null'>AND create_time &lt; #{endTime}</if>",
") t",
"</script>"})
BigDecimal getGameConsumeByUserIds(@Param("userIds") List<Integer> userIds, @Param("sources") List<Integer> sources,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 佣金明细记录
*/
List<Map<String, Object>> getCommissionDetailRecords(
@Param("userIds") List<Integer> userIds,
@Param("date") String date,
@Param("subUserId") Integer subUserId);
/**
* 招商充值返利明细(实时计算口径):查询所有下级充值记录,按招商充值返点比例计算佣金。
* JOIN tt_promotion_record 获取下级手机号。
*/
List<Map<String, Object>> getBusinessRechargeDetailRecords(
@Param("rechargeUserIds") List<Integer> rechargeUserIds,
@Param("date") String date,
@Param("subUserId") Integer subUserId);
/**
* 招商充值返利日报(实时计算口径):按 下级+日期 分组。
*/
List<Map<String, Object>> getBusinessRechargeDailyRecords(
@Param("rechargeUserIds") List<Integer> rechargeUserIds,
@Param("date") String date,
@Param("subUserId") Integer subUserId);
/**
* 招商充值返利汇总(实时计算口径):按 下级 分组。
*/
List<Map<String, Object>> getBusinessRechargeSummaryRecords(
@Param("rechargeUserIds") List<Integer> rechargeUserIds,
@Param("startDate") String startDate,
@Param("endDate") String endDate,
@Param("subUserId") Integer subUserId);
/**
* 招商佣金明细(实时计算口径):按招商自身费率对整队普通用户每笔游戏消费计算逐笔明细。
* 仅用于展示,发放仍由 CommissionSettlementTask 周结按 source 13 汇总入账。
*/
List<Map<String, Object>> getBusinessCommissionDetailRecords(
@Param("gameUserIds") List<Integer> gameUserIds,
@Param("date") String date,
@Param("subUserId") Integer subUserId);
/**
* 招商佣金日报(实时计算口径):按 下级+游戏+日期 分组,消费金额在 SQL 汇总,佣金在 Java 按招商费率计算。
*/
List<Map<String, Object>> getBusinessCommissionDailyRecords(
@Param("gameUserIds") List<Integer> gameUserIds,
@Param("date") String date,
@Param("subUserId") Integer subUserId);
/**
* 招商佣金汇总(实时计算口径):按 下级+游戏 分组,消费金额在 SQL 汇总,佣金在 Java 按招商费率计算。
*/
List<Map<String, Object>> getBusinessCommissionSummaryRecords(
@Param("gameUserIds") List<Integer> gameUserIds,
@Param("startDate") String startDate,
@Param("endDate") String endDate,
@Param("subUserId") Integer subUserId);
/**
* 佣金日报
*/
List<Map<String, Object>> getCommissionDailyRecords(
@Param("userIds") List<Integer> userIds,
@Param("date") String date,
@Param("subUserId") Integer subUserId);
/**
* 佣金汇总
*/
List<Map<String, Object>> getCommissionSummaryRecords(
@Param("userIds") List<Integer> userIds,
@Param("startDate") String startDate,
@Param("endDate") String endDate,
@Param("subUserId") Integer subUserId);
/**
* 下级充值报表
*/
List<Map<String, Object>> getSubordinateRechargeRecords(
@Param("parentId") Integer parentId,
@Param("month") String month,
@Param("subUserId") Integer subUserId,
@Param("phone") String phone);
}
@@ -0,0 +1,26 @@
package com.ruoyi.promo.model;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import lombok.Data;
/**
* 推广后台滑块验证码校验请求
*/
@Data
public class PromoCaptchaCheckRequest {
/**
* 验证码 ID
*/
private String id;
/**
* 滑块轨迹数据
*/
private ImageCaptchaTrack data;
/**
* 手机号,用于绑定验证状态
*/
private String phoneNumber;
}
@@ -0,0 +1,45 @@
package com.ruoyi.promo.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.promo.domain.entity.PromoCardKey;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 卡商卡密服务
*/
public interface PromoCardKeyService {
/**
* 生成卡密
* @param creatorId 卡商ID
* @param creatorName 卡商名称
* @param amount 卡密面值金额
* @param num 生成数量
* @return 生成的卡密列表
*/
R<List<String>> generateCardKeys(Long creatorId, String creatorName, BigDecimal amount, Integer num);
/**
* 卡商销毁自己创建的未使用卡密:软删除(status=2)并按面值退回卡商余额(merchant_balance),写退款流水。
* 幂等:仅当卡密仍为未使用(status=0)时可销毁,防止重复销毁/重复退款。
* @param cardKeyId 卡密ID
* @param operatorId 操作者(当前登录卡商)ID
* @param isSuperAdmin 是否超级管理员(超管可销毁任意卡商的未使用卡密)
* @return 销毁结果
*/
R<String> destroyCardKey(Long cardKeyId, Long operatorId, boolean isSuperAdmin);
/**
* 获取卡商的卡密列表(分页)
*/
IPage<PromoCardKey> getCardKeyList(Long creatorId, String usedByName, String creatorName, Integer pageNum, Integer pageSize);
/**
* 获取卡商信息(余额等)
*/
Map<String, Object> getMerchantInfo(Long userId);
}
@@ -0,0 +1,73 @@
package com.ruoyi.promo.service;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.promo.domain.entity.PromoCdkey;
import com.ruoyi.promo.domain.entity.PromoCdkeyRedeem;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 推广CDKEY服务
*/
public interface PromoCdkeyService {
/**
* 生成CDKEY(仅主播账号可创建)
* @param creatorId 创建者ID
* @param creatorName 创建者名称
* @param form 表单参数
* @return 生成的CDKEY码列表
*/
R<List<String>> generateCdkeys(Long creatorId, String creatorName, Map<String, Object> form);
/**
* 获取创建记录列表(当前登录主播自己创建的)
* @param creatorId 创建者ID
* @param startDate 开始日期
* @param endDate 结束日期
* @param status 状态(0=进行中,1=已结束)
*/
List<PromoCdkey> getCdkeyList(Long creatorId, Date startDate, Date endDate, Integer status);
/**
* 获取兑换记录列表(当前主播创建的CDKEY被下级普通用户兑换的记录)
* @param creatorId CDKEY创建者ID
* @param startDate 开始日期
* @param endDate 结束日期
* @param redeemerName 兑换人名称
* @param cdkeyCode CDKEY码
*/
List<PromoCdkeyRedeem> getRedeemList(Long creatorId, Date startDate, Date endDate,
String redeemerName, String cdkeyCode);
/**
* 创建日报
*/
List<Map<String, Object>> getCreateDailyReport(Long creatorId, Date startDate, Date endDate);
/**
* 兑换日报
*/
List<Map<String, Object>> getRedeemDailyReport(Long creatorId, Date startDate, Date endDate);
/**
* 全局补齐已过期状态(定时任务使用)
* @return 受影响行数
*/
int markExpiredCdkeys();
/**
* 查询待退还的CDK:已结束(status=1)且未退还(refund_time IS NULL)
*/
List<PromoCdkey> selectRefundableCdkeys();
/**
* 对单个CDK执行精确退还(预扣总额 - 实际派发),金币与积分同额。
* 幂等:重复调用或已退还时返回 0。
* @return 本次实际退还金额
*/
BigDecimal refundOne(PromoCdkey cdkey);
}
@@ -0,0 +1,32 @@
package com.ruoyi.promo.service;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.domain.entity.TtRechargeProd;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.promo.contract.request.RegisterRequest;
import com.ruoyi.promo.contract.request.UpdateInvitationCodeRequest;
import com.ruoyi.promo.contract.response.GetShareDomainResponse;
import java.util.List;
public interface PromoLoginService {
String login(String username, String password);
/**
* 推广后台短信验证码登录;手机号未注册时,带上级邀请码则自动注册为主播
*/
String smsLogin(String phoneNumber, String code, String parentInvitationCode);
String createUserToken(String phoneNumber);
R<TtUser> register(RegisterRequest request);
R<TtUser> updateInvitationCode(UpdateInvitationCodeRequest request);
R<GetShareDomainResponse> getShareDomain();
List<TtRechargeProd> queryList();
List<String> generateCard(Integer userId, Integer rechargeListId, Integer num);
}
@@ -0,0 +1,32 @@
package com.ruoyi.promo.service;
import com.ruoyi.promo.domain.entity.PromoMenuConfig;
import java.util.List;
/**
* 推广后台菜单权限服务(按角色类型分配)
*/
public interface PromoMenuService {
/**
* 根据用户类型获取菜单路径
* 超级管理员返回所有菜单
*/
List<String> getMenuPathsByUserType(String userType);
/**
* 超管:保存某角色类型的菜单权限
*/
void saveRoleMenus(String roleType, List<String> menuPaths);
/**
* 获取某角色类型已配置的菜单路径
*/
List<String> getRoleMenuPaths(String roleType);
/**
* 获取所有可分配的菜单配置(从数据库读取)
*/
List<PromoMenuConfig> getAllMenuConfigs();
}
@@ -0,0 +1,76 @@
package com.ruoyi.promo.service;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.domain.entity.TtCommissionRecord;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.promo.contract.request.GetPurchaseByUserIdRequest;
import com.ruoyi.promo.contract.request.GetSubBranchesRequest;
import com.ruoyi.promo.contract.request.QueryPromoteWelfareRequest;
import com.ruoyi.promo.contract.response.GetSubBranchesResponse;
import com.ruoyi.promo.contract.response.QueryPromotionWelfareResponse;
import com.ruoyi.promo.domain.dto.AnchorDayTurnoverDTO;
import com.ruoyi.promo.domain.vo.AnchorDayTurnoverVO;
import java.math.BigDecimal;
import java.util.List;
public interface PromoTurnoverService {
/**
* 递归全部最下级玩家
*/
List<TtUser> recursionAllPlayer(Integer userId);
/**
* 获取实时数据
*/
AjaxResult getRealTimeData(Integer userId);
/**
* 获取首页游戏返利看板数据(今日/昨日/本周/累计,按游戏类型拆分)
*/
AjaxResult getGameCommissionData(Integer userId);
/**
* 获取近10天推广数据
*/
AjaxResult getLast10DaysPromotionData(Integer userId);
/**
* 获取名下主播每日流水
*/
List<AnchorDayTurnoverVO> getAnchorDayTurnover(AnchorDayTurnoverDTO anchorDayTurnoverDTO);
/**
* 获取下级分支
*/
GetSubBranchesResponse getSubBranches(GetSubBranchesRequest request);
/**
* 获取日流水记录
*/
TableDataInfo getPurchaseByUserId(GetPurchaseByUserIdRequest userId);
/**
* 获取佣金比例
*/
BigDecimal getCommissionRateByUserId(Integer userId);
/**
* 修改佣金比例
*/
int updateCommissionRate(Integer userId, BigDecimal commissionRate);
/**
* 获取佣金列表
*/
List<TtCommissionRecord> getCommissionList(Integer userId);
QueryPromotionWelfareResponse queryPromoteWelfare(Integer userId, QueryPromoteWelfareRequest request);
/**
* 递归获取所有下级用户ID(包含自身,招商→主播+普通用户)
*/
void getAllSubIdsRecursive(Integer parentId, List<Integer> subUserIds);
}
@@ -0,0 +1,199 @@
package com.ruoyi.promo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.admin.mapper.TtRechargeProdMapper;
import com.ruoyi.admin.mapper.TtUserBlendErcashMapper;
import com.ruoyi.admin.mapper.TtUserMapper;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.domain.common.constant.TtAccountRecordSource;
import com.ruoyi.domain.common.constant.TtAccountRecordType;
import com.ruoyi.domain.entity.TtRechargeProd;
import com.ruoyi.domain.entity.TtUserBlendErcash;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.promo.domain.entity.PromoCardKey;
import com.ruoyi.promo.mapper.PromoCardKeyMapper;
import com.ruoyi.promo.service.PromoCardKeyService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.util.*;
@Service
public class PromoCardKeyServiceImpl implements PromoCardKeyService {
@Autowired
private PromoCardKeyMapper promoCardKeyMapper;
@Autowired
private TtRechargeProdMapper rechargeProdMapper;
@Autowired
private TtUserMapper ttUserMapper;
@Autowired
private TtUserBlendErcashMapper blendErcashMapper;
@Override
@Transactional
public R<List<String>> generateCardKeys(Long creatorId, String creatorName, BigDecimal amount, Integer num) {
if (num == null || num <= 0 || num > 100) {
return R.fail("生成数量必须在1-100之间");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
return R.fail("金额必须大于0");
}
BigDecimal unitPrice = amount;
BigDecimal totalCost = unitPrice.multiply(BigDecimal.valueOf(num));
// 扣除卡商专用余额(merchant_balance)
int rows = ttUserMapper.subtractMerchantBalance(creatorId, totalCost);
if (rows <= 0) {
return R.fail("卡商余额不足,需要" + totalCost.toPlainString() + "卡商余额");
}
// 查询扣减后余额并记录流水
TtUser afterUser = ttUserMapper.selectById(creatorId);
BigDecimal newBalance = afterUser.getMerchantBalance() != null ? afterUser.getMerchantBalance() : BigDecimal.ZERO;
TtUserBlendErcash ercash = TtUserBlendErcash.builder()
.userId(creatorId.intValue())
.amount(BigDecimal.ZERO)
.finalAmount(afterUser.getAccountAmount())
.credits(totalCost.negate())
.finalCredits(newBalance)
.total(totalCost.negate())
.type(TtAccountRecordType.OUTPUT.getCode())
.source(TtAccountRecordSource.CARD_MERCHANT_GENERATE.getCode())
.remark(TtAccountRecordSource.CARD_MERCHANT_GENERATE.getMsg() + "(" + num + "张)")
.createTime(new Timestamp(System.currentTimeMillis()))
.updateTime(new Timestamp(System.currentTimeMillis()))
.build();
blendErcashMapper.insert(ercash);
// 批量生成卡密
List<String> cardCodes = new ArrayList<>();
for (int i = 0; i < num; i++) {
String code = IdUtils.fastSimpleUUID().substring(0, 16).toUpperCase();
PromoCardKey cardKey = PromoCardKey.builder()
.cardCode(code)
.amount(unitPrice)
.creatorId(creatorId)
.creatorName(creatorName)
.status(0)
.createTime(DateUtils.getNowDate())
.build();
promoCardKeyMapper.insert(cardKey);
cardCodes.add(code);
}
return R.ok(cardCodes);
}
@Override
@Transactional
public R<String> destroyCardKey(Long cardKeyId, Long operatorId, boolean isSuperAdmin) {
if (cardKeyId == null) {
return R.fail("参数不完整");
}
PromoCardKey cardKey = promoCardKeyMapper.selectById(cardKeyId);
if (cardKey == null) {
return R.fail("卡密不存在");
}
// 权限:卡商仅能销毁自己创建的卡密,超管可销毁任意
if (!isSuperAdmin && (cardKey.getCreatorId() == null || !cardKey.getCreatorId().equals(operatorId))) {
return R.fail("无权销毁他人创建的卡密");
}
// 前置友好校验:已使用/已销毁不可再销毁
Integer status = cardKey.getStatus();
if (status != null && status == 1) {
return R.fail("该卡密已被使用,不能销毁");
}
if (status != null && status == 2) {
return R.fail("该卡密已销毁,请勿重复操作");
}
// 原子幂等:仅当仍为未使用(status=0)时置为已销毁(status=2),防并发兑换/重复销毁导致重复退款
LambdaUpdateWrapper<PromoCardKey> uw = new LambdaUpdateWrapper<>();
uw.eq(PromoCardKey::getId, cardKeyId)
.eq(PromoCardKey::getStatus, 0)
.set(PromoCardKey::getStatus, 2);
int marked = promoCardKeyMapper.update(null, uw);
if (marked <= 0) {
// 期间被兑换或被其他请求销毁
return R.fail("卡密状态已变更(可能已被使用或已销毁),操作未生效");
}
// 按面值全额退回卡商余额,退给卡密创建者
BigDecimal refund = cardKey.getAmount() != null ? cardKey.getAmount() : BigDecimal.ZERO;
Long creatorId = cardKey.getCreatorId();
if (refund.compareTo(BigDecimal.ZERO) > 0 && creatorId != null) {
ttUserMapper.addMerchantBalance(creatorId, refund);
// 写退款流水(镜像生成时的卡商余额记账约定:credits 携带卡商余额变动,finalCredits 为变动后余额)
TtUser afterUser = ttUserMapper.selectById(creatorId);
BigDecimal newBalance = (afterUser != null && afterUser.getMerchantBalance() != null)
? afterUser.getMerchantBalance() : BigDecimal.ZERO;
BigDecimal afterAccountAmount = (afterUser != null && afterUser.getAccountAmount() != null)
? afterUser.getAccountAmount() : BigDecimal.ZERO;
Timestamp now = new Timestamp(System.currentTimeMillis());
TtUserBlendErcash ercash = TtUserBlendErcash.builder()
.userId(creatorId.intValue())
.amount(BigDecimal.ZERO)
.finalAmount(afterAccountAmount)
.credits(refund)
.finalCredits(newBalance)
.total(refund)
.type(TtAccountRecordType.INPUT.getCode())
.source(TtAccountRecordSource.CARD_MERCHANT_DESTROY_REFUND.getCode())
.remark(TtAccountRecordSource.CARD_MERCHANT_DESTROY_REFUND.getMsg() + "(" + cardKey.getCardCode() + ")")
.createTime(now)
.updateTime(now)
.build();
blendErcashMapper.insert(ercash);
}
return R.ok("销毁成功,已退回卡商余额 " + refund.toPlainString());
}
@Override
public IPage<PromoCardKey> getCardKeyList(Long creatorId, String usedByName, String creatorName, Integer pageNum, Integer pageSize) {
LambdaQueryWrapper<PromoCardKey> wrapper = new LambdaQueryWrapper<>();
if (creatorId != null) {
wrapper.eq(PromoCardKey::getCreatorId, creatorId);
}
if (StringUtils.isNotBlank(usedByName)) {
wrapper.like(PromoCardKey::getUsedByName, usedByName);
}
if (StringUtils.isNotBlank(creatorName)) {
wrapper.like(PromoCardKey::getCreatorName, creatorName);
}
// 按创建时间倒序,相同时间再按 id 倒序,保证同一批次生成的卡密顺序稳定
wrapper.orderByDesc(PromoCardKey::getCreateTime).orderByDesc(PromoCardKey::getId);
Page<PromoCardKey> page = new Page<>(
pageNum == null || pageNum < 1 ? 1 : pageNum,
pageSize == null || pageSize < 1 ? 10 : pageSize);
return promoCardKeyMapper.selectPage(page, wrapper);
}
@Override
public Map<String, Object> getMerchantInfo(Long userId) {
TtUser user = ttUserMapper.selectById(userId);
Map<String, Object> info = new LinkedHashMap<>();
if (user != null) {
info.put("userName", user.getUserName());
info.put("nickName", user.getNickName());
info.put("balance", user.getMerchantBalance() != null ? user.getMerchantBalance() : BigDecimal.ZERO);
}
return info;
}
}
@@ -0,0 +1,389 @@
package com.ruoyi.promo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.admin.mapper.TtUserBlendErcashMapper;
import com.ruoyi.admin.mapper.TtUserMapper;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.domain.common.constant.TtAccountRecordSource;
import com.ruoyi.domain.common.constant.TtAccountRecordType;
import com.ruoyi.domain.entity.TtUserBlendErcash;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.promo.domain.entity.PromoCdkey;
import com.ruoyi.promo.domain.entity.PromoCdkeyRedeem;
import com.ruoyi.promo.mapper.PromoCdkeyMapper;
import com.ruoyi.promo.mapper.PromoCdkeyRedeemMapper;
import com.ruoyi.promo.service.PromoCdkeyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Timestamp;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
/**
* 推广CDKEY服务实现
*/
@Slf4j
@Service
public class PromoCdkeyServiceImpl implements PromoCdkeyService {
@Autowired
private PromoCdkeyMapper promoCdkeyMapper;
@Autowired
private PromoCdkeyRedeemMapper promoCdkeyRedeemMapper;
@Autowired
private TtUserMapper ttUserMapper;
@Autowired
private TtUserBlendErcashMapper blendErcashMapper;
/**
* 生成CDKEY
* 表单参数:
* title - CDKEY标题
* amount1 - 最低金额(金币)
* amount2 - 最高金额(金币)
* num - 每个码可用次数
* description - 描述
* openingTime - 开启时间
* validity - 有效期
* createNum - 生成码的数量
*/
@Override
@Transactional
public R<List<String>> generateCdkeys(Long creatorId, String creatorName, Map<String, Object> form) {
String title = (String) form.get("title");
if (title == null || title.trim().isEmpty()) {
return R.fail("CDKEY标题不能为空");
}
// 解析金额
BigDecimal amount1 = parseBigDecimal(form.get("amount1"));
BigDecimal amount2 = parseBigDecimal(form.get("amount2"));
if (amount1 == null || amount1.compareTo(BigDecimal.ZERO) < 0) {
return R.fail("最低金额不合法");
}
if (amount2 == null || amount2.compareTo(amount1) < 0) {
return R.fail("最高金额不能小于最低金额");
}
// 解析可用次数(设置上限,防止超大参数导致天量预扣计算/异常数据)
Integer num = parseInteger(form.get("num"));
if (num == null || num <= 0 || num > 1000) {
return R.fail("CDKEY个数必须在1-1000之间");
}
// 解析生成数量
Integer createNum = parseInteger(form.get("createNum"));
if (createNum == null || createNum <= 0 || createNum > 100) {
return R.fail("生成数量必须在1-100之间");
}
String description = (String) form.get("description");
// 解析时间
Date openingTime = parseDate(form.get("openingTime"));
if (openingTime == null) {
return R.fail("开启时间不能为空");
}
Date validity = parseDate(form.get("validity"));
// 计算总扣款金额 = 最高金额 × 每个码可用次数 × 生成数量(按最大可能派奖金额预扣)
BigDecimal totalCost = amount2.multiply(BigDecimal.valueOf(num)).multiply(BigDecimal.valueOf(createNum));
// 校验主播余额是否充足
TtUser anchorUser = ttUserMapper.selectById(creatorId);
if (anchorUser == null) {
return R.fail("主播账号不存在");
}
BigDecimal currentBalance = anchorUser.getAccountAmount() != null ? anchorUser.getAccountAmount() : BigDecimal.ZERO;
BigDecimal currentCredits = anchorUser.getAccountCredits() != null ? anchorUser.getAccountCredits() : BigDecimal.ZERO;
if (currentBalance.compareTo(totalCost) < 0) {
return R.fail("余额不足,需要 " + totalCost.toPlainString() + " 金币,当前余额 " + currentBalance.toPlainString());
}
if (currentCredits.compareTo(totalCost) < 0) {
return R.fail("积分不足,需要 " + totalCost.toPlainString() + " 积分,当前积分 " + currentCredits.toPlainString());
}
// 扣减主播余额(金币)
int rows = ttUserMapper.subtractOnlyAccountAmount(creatorId, totalCost);
if (rows <= 0) {
return R.fail("余额扣减失败,余额不足");
}
// 扣减主播积分
rows = ttUserMapper.subtractOnlyAccountCredits(creatorId, totalCost);
if (rows <= 0) {
// 积分不足时事务回滚
throw new RuntimeException("积分扣减失败,积分不足");
}
// 查询扣减后余额,写入流水
TtUser afterUser = ttUserMapper.selectById(creatorId);
BigDecimal newBalance = afterUser.getAccountAmount() != null ? afterUser.getAccountAmount() : BigDecimal.ZERO;
BigDecimal newCredits = afterUser.getAccountCredits() != null ? afterUser.getAccountCredits() : BigDecimal.ZERO;
Timestamp now = new Timestamp(System.currentTimeMillis());
TtUserBlendErcash ercash = TtUserBlendErcash.builder()
.userId(afterUser.getUserId())
.amount(totalCost.negate())
.finalAmount(newBalance)
.credits(totalCost.negate())
.finalCredits(newCredits)
.total(totalCost.negate())
.type(TtAccountRecordType.OUTPUT.getCode())
.source(TtAccountRecordSource.PROMO_CDKEY_GENERATE.getCode())
.remark(TtAccountRecordSource.PROMO_CDKEY_GENERATE.getMsg() + "(" + createNum + "个码×" + num + "次)")
.createTime(now)
.updateTime(now)
.build();
blendErcashMapper.insert(ercash);
// 批量生成CDKEY
List<String> cdkeyCodes = new ArrayList<>();
for (int i = 0; i < createNum; i++) {
String code = generateCdkeyCode();
PromoCdkey cdkey = PromoCdkey.builder()
.title(title.trim())
.cdkeyCode(code)
.amountMin(amount1)
.amountMax(amount2)
.num(num)
.usableCount(num)
.usedCount(0)
.description(description)
.openingTime(openingTime)
.validity(validity)
.status(0) // 进行中
.creatorId(creatorId)
.creatorName(creatorName)
.createTime(DateUtils.getNowDate())
.build();
promoCdkeyMapper.insert(cdkey);
cdkeyCodes.add(code);
}
return R.ok(cdkeyCodes, "生成成功");
}
@Override
public List<PromoCdkey> getCdkeyList(Long creatorId, Date startDate, Date endDate, Integer status) {
// 自动将已过期的CDKEY状态更新为已结束
LambdaQueryWrapper<PromoCdkey> expireWrapper = new LambdaQueryWrapper<>();
expireWrapper.eq(PromoCdkey::getCreatorId, creatorId)
.eq(PromoCdkey::getStatus, 0)
.le(PromoCdkey::getValidity, new Date());
List<PromoCdkey> expiredList = promoCdkeyMapper.selectList(expireWrapper);
if (!expiredList.isEmpty()) {
List<Long> expiredIds = new ArrayList<>();
for (PromoCdkey c : expiredList) {
expiredIds.add(c.getId());
}
PromoCdkey updateEntity = new PromoCdkey();
updateEntity.setStatus(1);
LambdaQueryWrapper<PromoCdkey> updateWrapper = new LambdaQueryWrapper<>();
updateWrapper.in(PromoCdkey::getId, expiredIds);
promoCdkeyMapper.update(updateEntity, updateWrapper);
}
LambdaQueryWrapper<PromoCdkey> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PromoCdkey::getCreatorId, creatorId);
if (startDate != null) {
wrapper.ge(PromoCdkey::getCreateTime, startDate);
}
if (endDate != null) {
wrapper.lt(PromoCdkey::getCreateTime, endDate);
}
if (status != null) {
if (status == 2) {
// “已过期”筛选: status=1 且 validity 已过
wrapper.eq(PromoCdkey::getStatus, 1)
.le(PromoCdkey::getValidity, new Date());
} else {
wrapper.eq(PromoCdkey::getStatus, status);
}
}
wrapper.orderByDesc(PromoCdkey::getCreateTime);
return promoCdkeyMapper.selectList(wrapper);
}
@Override
public List<PromoCdkeyRedeem> getRedeemList(Long creatorId, Date startDate, Date endDate,
String redeemerName, String cdkeyCode) {
// 先查出当前主播创建的所有CDKEY id
LambdaQueryWrapper<PromoCdkey> cdkeyWrapper = new LambdaQueryWrapper<>();
cdkeyWrapper.eq(PromoCdkey::getCreatorId, creatorId);
List<PromoCdkey> cdkeys = promoCdkeyMapper.selectList(cdkeyWrapper);
if (cdkeys.isEmpty()) {
return Collections.emptyList();
}
List<Long> cdkeyIds = new ArrayList<>();
for (PromoCdkey c : cdkeys) {
cdkeyIds.add(c.getId());
}
LambdaQueryWrapper<PromoCdkeyRedeem> wrapper = new LambdaQueryWrapper<>();
wrapper.in(PromoCdkeyRedeem::getCdkeyId, cdkeyIds);
if (startDate != null) {
wrapper.ge(PromoCdkeyRedeem::getRedeemTime, startDate);
}
if (endDate != null) {
wrapper.lt(PromoCdkeyRedeem::getRedeemTime, endDate);
}
if (redeemerName != null && !redeemerName.trim().isEmpty()) {
wrapper.like(PromoCdkeyRedeem::getRedeemerName, redeemerName.trim());
}
if (cdkeyCode != null && !cdkeyCode.trim().isEmpty()) {
wrapper.like(PromoCdkeyRedeem::getCdkeyCode, cdkeyCode.trim());
}
wrapper.orderByDesc(PromoCdkeyRedeem::getRedeemTime);
return promoCdkeyRedeemMapper.selectList(wrapper);
}
@Override
public List<Map<String, Object>> getCreateDailyReport(Long creatorId, Date startDate, Date endDate) {
return promoCdkeyMapper.getCreateDailyReport(creatorId, startDate, endDate);
}
@Override
public List<Map<String, Object>> getRedeemDailyReport(Long creatorId, Date startDate, Date endDate) {
return promoCdkeyRedeemMapper.getRedeemDailyReport(creatorId, startDate, endDate);
}
@Override
public int markExpiredCdkeys() {
return promoCdkeyMapper.markExpired();
}
@Override
public List<PromoCdkey> selectRefundableCdkeys() {
LambdaQueryWrapper<PromoCdkey> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PromoCdkey::getStatus, 1)
.isNull(PromoCdkey::getRefundTime);
return promoCdkeyMapper.selectList(wrapper);
}
@Override
@Transactional
public BigDecimal refundOne(PromoCdkey cdkey) {
Long id = cdkey.getId();
// 精确退还 = 预扣总额(amountMax×num) - 实际已派发金币总额
BigDecimal amountMax = cdkey.getAmountMax() != null ? cdkey.getAmountMax() : BigDecimal.ZERO;
int num = cdkey.getNum() != null ? cdkey.getNum() : 0;
BigDecimal prepaid = amountMax.multiply(BigDecimal.valueOf(num));
BigDecimal distributed = promoCdkeyRedeemMapper.sumRedeemedAmount(id);
if (distributed == null) {
distributed = BigDecimal.ZERO;
}
BigDecimal refund = prepaid.subtract(distributed);
if (refund.compareTo(BigDecimal.ZERO) < 0) {
refund = BigDecimal.ZERO;
}
// 幂等抢占:仅当 refund_time 为空时标记成功,防并发/重复退还
Date now = new Date();
int marked = promoCdkeyMapper.markRefunded(id, refund, now);
if (marked <= 0) {
return BigDecimal.ZERO;
}
if (refund.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
// 回补主播金币与积分(同额,与创建时双向预扣对应)
Long creatorId = cdkey.getCreatorId();
ttUserMapper.addOnlyAccountAmount(creatorId, refund);
ttUserMapper.addOnlyAccountCredits(creatorId, refund);
// 写入退还流水
TtUser afterUser = ttUserMapper.selectById(creatorId);
if (afterUser == null) {
log.warn("[CDK结束退还] creatorId={} 对应的用户不存在(可能已被删除),本次退还中止", creatorId);
return BigDecimal.ZERO;
}
BigDecimal newBalance = afterUser.getAccountAmount() != null ? afterUser.getAccountAmount() : BigDecimal.ZERO;
BigDecimal newCredits = afterUser.getAccountCredits() != null ? afterUser.getAccountCredits() : BigDecimal.ZERO;
Timestamp ts = new Timestamp(System.currentTimeMillis());
TtUserBlendErcash ercash = TtUserBlendErcash.builder()
.userId(afterUser.getUserId())
.amount(refund)
.finalAmount(newBalance)
.credits(refund)
.finalCredits(newCredits)
.total(refund)
.type(TtAccountRecordType.INPUT.getCode())
.source(TtAccountRecordSource.PROMO_CDKEY_REFUND.getCode())
.remark(TtAccountRecordSource.PROMO_CDKEY_REFUND.getMsg() + "(" + cdkey.getCdkeyCode() + ")")
.createTime(ts)
.updateTime(ts)
.build();
blendErcashMapper.insert(ercash);
return refund;
}
// ================= 工具方法 =================
/**
* 生成16位CDKEY码,格式: XXXX-XXXX-XXXX-XXXX
*/
private String generateCdkeyCode() {
String raw = IdUtils.fastSimpleUUID().substring(0, 16).toUpperCase();
return raw.substring(0, 4) + "-" + raw.substring(4, 8) + "-"
+ raw.substring(8, 12) + "-" + raw.substring(12, 16);
}
private BigDecimal parseBigDecimal(Object val) {
if (val == null) return null;
try {
if (val instanceof Number) {
return BigDecimal.valueOf(((Number) val).doubleValue()).setScale(2, RoundingMode.HALF_UP);
}
String s = val.toString().trim();
if (s.isEmpty()) return null;
return new BigDecimal(s).setScale(2, RoundingMode.HALF_UP);
} catch (Exception e) {
return null;
}
}
private Integer parseInteger(Object val) {
if (val == null) return null;
if (val instanceof Number) {
return ((Number) val).intValue();
}
try {
return Integer.parseInt(val.toString().trim());
} catch (Exception e) {
return null;
}
}
private Date parseDate(Object val) {
if (val == null) return null;
if (val instanceof Date) return (Date) val;
String s = val.toString().trim();
if (s.isEmpty()) return null;
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return sdf.parse(s);
} catch (ParseException e) {
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
return sdf.parse(s);
} catch (ParseException e2) {
return null;
}
}
}
}
@@ -0,0 +1,533 @@
package com.ruoyi.promo.service.impl;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.NumberUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper;
import com.ruoyi.admin.mapper.PromoAdminMapper;
import com.ruoyi.admin.mapper.TtRechargeCardMapper;
import com.ruoyi.admin.mapper.TtRechargeProdMapper;
import com.ruoyi.admin.mapper.TtRedPacketMapper;
import com.ruoyi.admin.mapper.TtUserMapper;
import com.ruoyi.admin.service.TtUserAvatarService;
import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.admin.util.RandomUtils;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.RandomUtil;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.common.utils.uuid.UUID;
import com.ruoyi.domain.common.constant.TtAccountRecordSource;
import com.ruoyi.domain.common.constant.UserType;
import com.ruoyi.domain.entity.TtRechargeProd;
import com.ruoyi.domain.entity.sys.PromoAdmin;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.domain.other.TtRedPacket;
import com.ruoyi.domain.other.TtUserAvatar;
import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.promo.contract.request.RegisterRequest;
import com.ruoyi.promo.contract.request.UpdateInvitationCodeRequest;
import com.ruoyi.promo.contract.response.GetShareDomainResponse;
import com.ruoyi.promo.service.PromoLoginService;
import com.ruoyi.system.domain.SysConfig;
import com.ruoyi.system.mapper.SysConfigMapper;
import com.ruoyi.thirdparty.common.service.ApiSmsService;
import com.ruoyi.admin.security.ApiUserDetailsServiceImpl;
import com.ruoyi.admin.service.UserIdGenerator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Random;
@Service
@Slf4j
public class PromoLoginServiceImpl implements PromoLoginService {
@Autowired
private SysLoginService sysLoginService;
@Autowired
private TokenService tokenService;
@Autowired
private ApiUserDetailsServiceImpl apiUserDetailsService;
@Autowired
private TtUserMapper ttUserMapper;
@Autowired
private PromoAdminMapper promoAdminMapper;
@Autowired
private TtUserService userService;
@Autowired
private TtUserAvatarService userAvatarService;
@Autowired
private ApiSmsService apiSmsService;
@Autowired
private UserIdGenerator userIdGenerator;
@Autowired
private SysConfigMapper sysConfigMapper;
@Autowired
private TtRechargeCardMapper rechargeCardMapper;
@Autowired
private TtRechargeProdMapper rechargeListMapper;
@Autowired
private TtRedPacketMapper redPacketMapper;
@Autowired
private RedisCache redisCache;
/** 推广码已修改标记 Redis Key 前缀 */
private static final String INVITATION_CODE_MODIFIED_KEY = "promo:invitation_code_modified:";
/**
* 推广后台账号密码登录(短信未接入前统一走这里):
* ① 超管 → promo_admin(username 或 phone_number)
* ② 招商(04)/主播(01)/卡商(05) → tt_user(账号为手机号)
*/
@Override
public String login(String username, String password) {
sysLoginService.loginPreCheck(username, password);
String account = username.trim();
// ① 超级管理员
PromoAdmin promoAdmin = promoAdminMapper.findByAccount(account);
if (promoAdmin != null) {
if (!SecurityUtils.matchesPassword(password, promoAdmin.getPassword())) {
throw new IllegalArgumentException("账号或密码错误");
}
// 走 promo_ 前缀,确保拿到的是超管身份(promo_admin 表)
LoginUser loginUser = sysLoginService.createLoginUser("promo_" + promoAdmin.getUsername(), password);
return tokenService.createToken(loginUser);
}
// ② 招商/主播/卡商
TtUser user = new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.and(w -> w.eq(TtUser::getPhoneNumber, account).or().eq(TtUser::getUserName, account))
.one();
if (user == null) {
throw new IllegalArgumentException("账号或密码错误");
}
if (!UserType.ANCHOR.getCode().equals(user.getUserType())
&& !UserType.BUSINESS_USER.getCode().equals(user.getUserType())
&& !UserType.CARD_MERCHANT.getCode().equals(user.getUserType())) {
throw new IllegalArgumentException("该账号无推广后台登录权限");
}
if (!SecurityUtils.matchesPassword(password, user.getPassword())) {
throw new IllegalArgumentException("账号或密码错误");
}
return createUserToken(user.getPhoneNumber());
}
/**
* 这个框既能填 4 位短信验证码,也能直接填密码:
* ① 非 4 位数字 → 按密码处理(超管 promo_admin / 招商·主播·卡商 tt_user)
* ② 4 位数字 → 按短信验证码处理
*/
@Override
public String smsLogin(String phoneNumber, String code, String parentInvitationCode) {
String account = StringUtils.trim(phoneNumber);
String value = StringUtils.trim(code);
PromoAdmin promoAdmin = StringUtils.isNotEmpty(account) ? promoAdminMapper.findByAccount(account) : null;
TtUser ttUser = StringUtils.isNotEmpty(account) ? findUserByAccount(account) : null;
boolean hasPromoRole = ttUser != null
&& !"1".equals(ttUser.getDelFlag())
&& !"1".equals(ttUser.getStatus())
&& (UserType.ANCHOR.getCode().equals(ttUser.getUserType())
|| UserType.BUSINESS_USER.getCode().equals(ttUser.getUserType())
|| UserType.CARD_MERCHANT.getCode().equals(ttUser.getUserType()));
if (promoAdmin == null && ttUser == null && !Validator.isMobile(account)) {
throw new IllegalArgumentException("手机号格式错误,请检查手机号是否输入正确!");
}
boolean isCode = NumberUtil.isNumber(value) && value.length() == 4;
// ① 密码登录
if (!isCode && StringUtils.isNotEmpty(value)) {
if (promoAdmin != null && StringUtils.isNotEmpty(promoAdmin.getPassword())
&& SecurityUtils.matchesPassword(value, promoAdmin.getPassword())) {
// 走 promo_ 前缀,确保拿到的是超管身份
LoginUser loginUser = sysLoginService.createLoginUser("promo_" + promoAdmin.getUsername(), value);
return tokenService.createToken(loginUser);
}
if (hasPromoRole && StringUtils.isNotEmpty(ttUser.getPassword())
&& SecurityUtils.matchesPassword(value, ttUser.getPassword())) {
return createUserToken(ttUser.getPhoneNumber());
}
if (promoAdmin != null || hasPromoRole) {
throw new IllegalArgumentException("密码错误");
}
if (ttUser != null) {
throw new IllegalArgumentException("该账号无推广后台登录权限");
}
throw new IllegalArgumentException("验证码错误");
}
// ② 短信验证码
if (!isCode) {
throw new IllegalArgumentException("验证码错误");
}
String validateCaptcha = apiSmsService.validateCaptcha(value, "ApiLogin_" + account);
if (!"success".equals(validateCaptcha)) {
throw new IllegalArgumentException(validateCaptcha);
}
// 手机号没注册过:带上级邀请码则自动注册为主播(口径与 App 的 verificationCodeLoginOrRegister 一致)
autoRegisterAnchorIfNeeded(account, parentInvitationCode);
return createUserToken(account);
}
/**
* 按手机号或用户名查 tt_user
*/
private TtUser findUserByAccount(String account) {
return new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.and(w -> w.eq(TtUser::getPhoneNumber, account).or().eq(TtUser::getUserName, account))
.one();
}
/**
* 推广后台:手机号未注册 + 有效招商邀请码 → 自动注册为主播(01)并挂在招商下
*/
private void autoRegisterAnchorIfNeeded(String phoneNumber, String parentInvitationCode) {
boolean registered = new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.eq(TtUser::getPhoneNumber, phoneNumber).count() > 0;
if (registered || promoAdminMapper.findByAccount(phoneNumber) != null) {
return;
}
if (StringUtils.isEmpty(parentInvitationCode)) {
throw new IllegalArgumentException("该手机号未注册,请填写上级邀请码后再登录");
}
TtUser parentUser = new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.eq(TtUser::getInvitationCode, parentInvitationCode.trim().toUpperCase())
.eq(TtUser::getDelFlag, "0")
.eq(TtUser::getStatus, "0").one();
if (Objects.isNull(parentUser) || !UserType.BUSINESS_USER.getCode().equals(parentUser.getUserType())) {
throw new IllegalArgumentException("上级邀请码填写错误!");
}
TtUser ttUser = buildPromoUser(phoneNumber, UserType.ANCHOR.getCode(),
cn.hutool.core.util.RandomUtil.randomString(10));
ttUser.setParentId(parentUser.getUserId());
ttUser.setUserId(userIdGenerator.generateUserId());
try {
ttUserMapper.insert(ttUser);
log.info("[推广后台自动注册主播] phone={} parentId={}", phoneNumber, parentUser.getUserId());
} catch (Exception e) {
log.error("推广后台自动注册主播失败", e);
throw new IllegalArgumentException("注册失败,请稍后重试");
}
}
@Override
public String createUserToken(String phoneNumber) {
sysLoginService.validateBlackIp(phoneNumber);
LoginUser loginUser = (LoginUser) apiUserDetailsService.loadUserByUsername("promo_user_" + phoneNumber);
// 超级管理员来自 promo_admin 表,其 id 与 tt_user 不同表,不能按 id 回写 tt_user 登录信息
if (!UserType.SUPER_ADMIN.getCode().equals(loginUser.getUserData().getUserType())) {
recordLoginInfo(loginUser.getUserId());
}
return tokenService.createToken(loginUser);
}
@Override
public R<TtUser> register(RegisterRequest request) {
if (StringUtils.isEmpty(request.getUsername())) return R.fail("手机号不能为空");
if (!Validator.isMobile(request.getUsername())) return R.fail("手机号格式错误,请检查手机号是否输入正确!");
if (StringUtils.isEmpty(request.getPassword())) return R.fail("用户密码不能为空");
if (StringUtils.isEmpty(request.getUserType())) return R.fail("注册角色不能为空");
if (!NumberUtil.isNumber(request.getCode()) || request.getCode().trim().length() != 4)
return R.fail("验证码错误");
// 推广后台注册始终只允许注册主播(01)账号,任何非主播角色一律拒绝。
// 招商(04)由后台「生成招商账号」(TtUserServiceImpl.getAccountList)创建,
// 卡商(05)由后台「生成卡商账号」(TtUserServiceImpl.getCardMerchantList)创建,均不经过本接口。
String userType = request.getUserType();
if (!UserType.ANCHOR.getCode().equals(userType)) {
return R.fail("推广后台仅支持注册主播账号");
}
// 主播必须填写上级邀请码
if (UserType.ANCHOR.getCode().equals(userType) && StringUtils.isEmpty(request.getParentInvitationCode())) {
return R.fail("主播注册必须填写上级邀请码");
}
if (!userService.checkPhoneUnique(TtUser.builder().phoneNumber(request.getUsername()).build()))
return R.fail("注册失败," + "手机号'" + request.getUsername() + "'已被注册!");
if (!userService.checkUserNameUnique(TtUser.builder().userName(request.getUsername()).build()))
return R.fail("注册失败," + "用户名'" + request.getUsername() + "'已存在!");
String phoneNumber = request.getUsername();
TtUser ttUser = buildPromoUser(phoneNumber, userType, request.getPassword());
// 主播需要验证上级邀请码(必须是招商的邀请码)
if (UserType.ANCHOR.getCode().equals(userType)) {
var parentInvitationCode = request.getParentInvitationCode();
TtUser parentUser = new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.eq(TtUser::getInvitationCode, parentInvitationCode.trim().toUpperCase())
.eq(TtUser::getDelFlag, "0")
.eq(TtUser::getStatus, "0").one();
if (Objects.isNull(parentUser) || !UserType.BUSINESS_USER.getCode().equals(parentUser.getUserType())) {
return R.fail("上级邀请码填写错误!");
}
ttUser.setParentId(parentUser.getUserId());
}
String validateCaptcha = apiSmsService.validateCaptcha(request.getCode().trim(), "ApiRegister_" + phoneNumber);
if (!"success".equals(validateCaptcha)) return R.fail(validateCaptcha);
try {
var id = userIdGenerator.generateUserId();
ttUser.setUserId(id);
ttUserMapper.insert(ttUser);
} catch (DuplicateKeyException e) {
return R.fail("手机号'" + request.getUsername() + "'已被注册!");
} catch (Exception e) {
log.error("用户注册失败", e);
return R.fail("注册失败,请稍后重试");
}
ttUser.setPassword(null);
return R.ok(ttUser);
}
/**
* 构造推广后台账号(注册与自动注册共用,避免两处口径漂移)
*/
private TtUser buildPromoUser(String phoneNumber, String userType, String rawPassword) {
String nickName = RandomUtils.getRandomName(new Random().nextInt(2));
TtUser ttUser = TtUser.builder().build();
ttUser.setUserName(phoneNumber);
ttUser.setNickName(nickName);
ttUser.setUserType(userType);
ttUser.setPhoneNumber(phoneNumber);
List<TtUserAvatar> userAvatarList = new LambdaQueryChainWrapper<>(userAvatarService.getBaseMapper())
.eq(TtUserAvatar::getIsDefault, "1").list();
if (!userAvatarList.isEmpty()) {
ttUser.setAvatar(RandomUtil.choice(userAvatarList).getAvatar());
} else {
ttUser.setAvatar("");
}
ttUser.setPassword(SecurityUtils.encryptPassword(rawPassword));
ttUser.setInvitationCode(getInvitationCode().toLowerCase());
ttUser.setIsRealCheck("0");
ttUser.setRemark("明文密码:" + rawPassword);
ttUser.setCreateBy("");
ttUser.setCreateTime(DateUtils.getNowDate());
ttUser.setAccountAmount(BigDecimal.ZERO);
ttUser.setAccountCredits(BigDecimal.ZERO);
// 主播(01)默认返点率:邀请新人充值2、新游戏消费1、经典玩法4
if (UserType.ANCHOR.getCode().equals(userType)) {
ttUser.setCommissionRate(BigDecimal.valueOf(0));
ttUser.setNewGameCommissionRate(BigDecimal.valueOf(1));
ttUser.setClassicGameCommissionRate(BigDecimal.valueOf(3.5));
}
// 招商(04)默认返点率:邀请新人充值0、新游戏消费0.5、经典玩法0.5
// 注:注册接口已收紧为仅允许主播(01),此分支为不可达代码,保留仅为减少改动面。
else if (UserType.BUSINESS_USER.getCode().equals(userType)) {
ttUser.setCommissionRate(BigDecimal.valueOf(0));
ttUser.setNewGameCommissionRate(new BigDecimal("0.5"));
ttUser.setClassicGameCommissionRate(new BigDecimal("0.5"));
}
return ttUser;
}
@Override
public R<TtUser> updateInvitationCode(UpdateInvitationCodeRequest request) {
// 验证参数
if (StringUtils.isEmpty(request.getUsername())) return R.fail("用户名不能为空");
if (StringUtils.isEmpty(request.getInvitationCode())) return R.fail("邀请码不能为空");
if (request.getInvitationCode().length() != 6) return R.fail("邀请码长度必须为 6 位");
// 从安全上下文中获取当前登录用户 ID,只允许修改自己的邀请码
Long currentUserId = SecurityUtils.getUserId();
// 校验目标用户必须是当前登录用户
TtUser user = new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.eq(TtUser::getUserId, currentUserId)
.eq(TtUser::getDelFlag, "0")
.one();
if (Objects.isNull(user)) {
return R.fail("用户不存在");
}
// 额外校验:如果按 username 传入,确保 username 属于当前用户
if (!Objects.equals(user.getUserName(), request.getUsername())) {
return R.fail("用户名不匹配,无权修改他人邀请码");
}
// 检查是否已经修改过邀请码(只允许修改一次)
String modifiedKey = INVITATION_CODE_MODIFIED_KEY + user.getUserId();
if (Boolean.TRUE.equals(redisCache.hasKey(modifiedKey))) {
return R.fail("邀请码只能修改一次");
}
// 检查邀请码是否已存在
TtUser existingUser = new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.eq(TtUser::getInvitationCode, request.getInvitationCode().toLowerCase())
.eq(TtUser::getDelFlag, "0")
.ne(TtUser::getUserId, user.getUserId()) // 排除当前用户
.one();
if (Objects.nonNull(existingUser)) {
return R.fail("邀请码已存在,请更换其他邀请码");
}
// 更新邀请码
try {
boolean updated = new LambdaUpdateChainWrapper<>(ttUserMapper)
.eq(TtUser::getUserId, user.getUserId())
.set(TtUser::getInvitationCode, request.getInvitationCode().toLowerCase())
.update();
if (!updated) {
return R.fail("更新邀请码失败");
}
// 标记该用户已修改过邀请码(永久标记)
redisCache.setCacheObject(modifiedKey, "1");
// 重新获取用户信息
user = new LambdaQueryChainWrapper<>(userService.getBaseMapper())
.eq(TtUser::getUserId, user.getUserId())
.one();
user.setPassword(null); // 清除密码
return R.ok(user);
} catch (Exception e) {
log.error("更新邀请码失败", e);
return R.fail("更新邀请码失败,请稍后重试");
}
}
/**
* 检查用户是否已修改过邀请码
*/
public boolean isInvitationCodeModified(Integer userId) {
String modifiedKey = INVITATION_CODE_MODIFIED_KEY + userId;
return Boolean.TRUE.equals(redisCache.hasKey(modifiedKey));
}
@Override
public R<GetShareDomainResponse> getShareDomain() {
GetShareDomainResponse resp = new GetShareDomainResponse();
SysConfig zhubo = sysConfigMapper.checkConfigKeyUnique("app.zhubo.sharelink");
SysConfig zhaoshang = sysConfigMapper.checkConfigKeyUnique("app.zhaoshang.sharelink");
SysConfig user = sysConfigMapper.checkConfigKeyUnique("app.user.sharelink");
if (zhubo != null) {
resp.setZhuboShareDomain(zhubo.getConfigValue());
}
if (zhaoshang != null) {
resp.setZhaoshaoShareDomain(zhaoshang.getConfigValue());
}
if (user != null) {
resp.setUserShareDomain(user.getConfigValue());
}
return R.ok(resp);
}
@Override
public List<TtRechargeProd> queryList() {
LambdaQueryWrapper<TtRechargeProd> wrapper = new LambdaQueryWrapper<>();
wrapper.ge(TtRechargeProd::getPrice, BigDecimal.valueOf(100));
return rechargeListMapper.selectList(wrapper);
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<String> generateCard(Integer userId, Integer rechargeListId, Integer num) {
if (num == null || num <= 0) return new ArrayList<>();
// 添加上限保护,防止资源耗尽攻击
if (num > 100) {
log.warn("生成卡片数量超限:userId={}, num={}", userId, num);
return new ArrayList<>();
}
TtRechargeProd recharge = rechargeListMapper.selectById(rechargeListId);
if (recharge == null) return new ArrayList<>();
BigDecimal totalPrice = recharge.getPrice().multiply(BigDecimal.valueOf(num));
var r = userService.updateUserCredits(userId, totalPrice.negate(), TtAccountRecordSource.ZHUBO_EXTRACT);
if (R.isError(r)) {
return new ArrayList<>();
}
try {
List<String> cardList = new ArrayList<>();
List<TtRedPacket> rpList = new ArrayList<>();
for (int i = 0; i < num; i++) {
TtRedPacket redPacket = new TtRedPacket();
redPacket.setNum(1);
redPacket.setUserId(userId);
redPacket.setTitle("福利");
redPacket.setOpeningTime(DateUtils.getNowDate());
redPacket.setAmount(String.format("[%s,%s]", recharge.getPrice().toPlainString(), recharge.getPrice().toPlainString()));
redPacket.setPassword(IdUtils.fastSimpleUUID().toUpperCase());
cardList.add(redPacket.getPassword());
redPacket.setCreateBy(SecurityUtils.getUsername());
redPacket.setCreateTime(DateUtils.getNowDate());
redPacket.setUseStatus(0);
rpList.add(redPacket);
}
redPacketMapper.insert(rpList);
return cardList;
} catch (Exception e) {
log.error("生成卡片失败:userId={}, rechargeListId={}, num={}", userId, rechargeListId, num, e);
// 扣款与发卡已在同一事务内(方法上的 @Transactional),异常时整体回滚即可,
// 不需要再手工补款:手工补款会额外写一条退还流水污染账目,且进程中途异常时补不回来
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return new ArrayList<>();
}
}
public String getInvitationCode() {
while (true) {
try {
String randomInvitationCode = UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase();
TtUser ttUser = new LambdaQueryChainWrapper<>(ttUserMapper)
.eq(TtUser::getInvitationCode, randomInvitationCode)
.eq(TtUser::getDelFlag, "0")
.one();
if (StringUtils.isNull(ttUser)) {
return randomInvitationCode;
}
} catch (Exception e) {
return null;
}
}
}
/**
* 记录登录信息
*/
public void recordLoginInfo(Long userId) {
new LambdaUpdateChainWrapper<>(ttUserMapper).eq(TtUser::getUserId, userId)
.set(TtUser::getLoginIp, IpUtils.getIpAddr())
.set(TtUser::getLoginDate, DateUtils.getNowDate()).update();
}
}
@@ -0,0 +1,96 @@
package com.ruoyi.promo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.domain.common.constant.UserType;
import com.ruoyi.promo.domain.entity.PromoMenuConfig;
import com.ruoyi.promo.domain.entity.PromoRoleMenu;
import com.ruoyi.promo.mapper.PromoMenuConfigMapper;
import com.ruoyi.promo.mapper.PromoRoleMenuMapper;
import com.ruoyi.promo.service.PromoMenuService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class PromoMenuServiceImpl implements PromoMenuService {
@Autowired
private PromoRoleMenuMapper promoRoleMenuMapper;
@Autowired
private PromoMenuConfigMapper promoMenuConfigMapper;
@Override
public List<String> getMenuPathsByUserType(String userType) {
// 从数据库获取该角色类型的菜单配置
List<String> paths = promoRoleMenuMapper.getMenuPathsByRoleType(userType);
if (paths == null || paths.isEmpty()) {
// 超级管理员(99)和超管(00)没有配置时,默认返回所有菜单
if ("99".equals(userType) || "00".equals(userType)) {
return getAllMenuPaths();
}
return new ArrayList<>();
}
return paths;
}
@Override
@Transactional
public void saveRoleMenus(String roleType, List<String> menuPaths) {
// 先删除该角色原有的菜单配置
promoRoleMenuMapper.delete(
new LambdaQueryWrapper<PromoRoleMenu>().eq(PromoRoleMenu::getRoleType, roleType)
);
// 批量插入新的菜单配置(数组顺序即排序顺序)
if (menuPaths != null && !menuPaths.isEmpty()) {
for (int i = 0; i < menuPaths.size(); i++) {
PromoRoleMenu menu = PromoRoleMenu.builder()
.roleType(roleType)
.menuPath(menuPaths.get(i))
.sortOrder(i)
.createTime(DateUtils.getNowDate())
.build();
promoRoleMenuMapper.insert(menu);
}
}
}
@Override
public List<String> getRoleMenuPaths(String roleType) {
return promoRoleMenuMapper.getMenuPathsByRoleType(roleType);
}
@Override
public List<PromoMenuConfig> getAllMenuConfigs() {
LambdaQueryWrapper<PromoMenuConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByAsc(PromoMenuConfig::getSortOrder);
return promoMenuConfigMapper.selectList(wrapper);
}
/**
* 从数据库获取所有菜单路径(带父路径展开)
*/
private List<String> getAllMenuPaths() {
List<PromoMenuConfig> configs = getAllMenuConfigs();
List<String> paths = new ArrayList<>();
for (PromoMenuConfig config : configs) {
String menuPath = config.getMenuPath();
// 添加父路径
String parentPath = menuPath.substring(0, menuPath.lastIndexOf('/'));
if (!parentPath.isEmpty() && !paths.contains(parentPath)) {
paths.add(parentPath);
}
// 添加子路径
if (!paths.contains(menuPath)) {
paths.add(menuPath);
}
}
return paths;
}
}
@@ -0,0 +1,877 @@
package com.ruoyi.promo.service.impl;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.admin.mapper.TtUserBlendErcashMapper;
import com.ruoyi.admin.mapper.TtUserMapper;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.utils.DateTimeUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.domain.common.constant.TtAccountRecordSource;
import com.ruoyi.domain.common.constant.TtAccountRecordType;
import com.ruoyi.domain.common.constant.UserType;
import com.ruoyi.domain.entity.TtCommissionRecord;
import com.ruoyi.domain.entity.TtUserBlendErcash;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.promo.contract.request.GetPurchaseByUserIdRequest;
import com.ruoyi.promo.contract.request.GetSubBranchesRequest;
import com.ruoyi.promo.contract.request.QueryPromoteWelfareRequest;
import com.ruoyi.promo.contract.response.GetSubBranchesResponse;
import com.ruoyi.promo.contract.response.QueryPromotionWelfareResponse;
import com.ruoyi.promo.contract.vo.DailyUserErcashVO;
import com.ruoyi.promo.domain.dto.AnchorDayTurnoverDTO;
import com.ruoyi.promo.domain.param.GetPlayersTurnoverParam;
import com.ruoyi.promo.domain.vo.AnchorDayTurnoverVO;
import com.ruoyi.promo.domain.vo.DayInviteVO;
import com.ruoyi.promo.domain.vo.DayTurnoverVO;
import com.ruoyi.promo.mapper.PromoTurnoverMapper;
import com.ruoyi.promo.service.PromoTurnoverService;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class PromoTurnoverServiceImpl implements PromoTurnoverService {
@Autowired
private PromoTurnoverMapper promoTurnoverMapper;
@Autowired
private TtUserBlendErcashMapper userBlendErcashMapper;
@Autowired
private TtUserMapper userMapper;
@Override
public AjaxResult getRealTimeData(Integer userId) {
// 确定当前用户类型,决定直属下级类型
TtUser currentUser = userMapper.selectById(userId);
String childType = UserType.COMMON_USER.getCode(); // 默认查询普通用户
if (currentUser != null) {
if (UserType.BUSINESS_USER.getCode().equals(currentUser.getUserType())) {
childType = UserType.ANCHOR.getCode(); // 招商的下级是主播
} else if (UserType.ANCHOR.getCode().equals(currentUser.getUserType())) {
childType = UserType.COMMON_USER.getCode(); // 主播的下级是普通用户
}
}
// 获取所有下级用户ID(一次递归,替代原来的 recursionAllPlayer + getAllSubUserIdsRecursive 两次遍历)
List<Integer> allSubUserIds = new ArrayList<>();
getAllSubUserIdsRecursive(userId, allSubUserIds);
// 招商用户:游戏消费只算普通用户(不含主播),充值仍算全部下级
boolean isBusiness = currentUser != null && UserType.BUSINESS_USER.getCode().equals(currentUser.getUserType());
List<Integer> gameUserIds = allSubUserIds;
if (isBusiness && !allSubUserIds.isEmpty()) {
Set<Integer> anchorIds = new LambdaQueryChainWrapper<>(userMapper)
.in(TtUser::getUserId, allSubUserIds)
.eq(TtUser::getUserType, UserType.ANCHOR.getCode())
.list()
.stream().map(TtUser::getUserId).collect(Collectors.toSet());
gameUserIds = allSubUserIds.stream()
.filter(id -> !anchorIds.contains(id))
.collect(Collectors.toList());
if (gameUserIds.isEmpty()) {
gameUserIds = Collections.singletonList(userId); // 避免空列表导致SQL报错
}
}
// 时间范围
Date todayStart = getStartOfToday().getTime();
Date todayEnd = new Date(todayStart.getTime() + 86400000L);
Date yesterdayStart = getStartOfYesterday().getTime();
Date yesterdayEnd = new Date(yesterdayStart.getTime() + 86400000L);
Map<String, Object> map = new HashMap<>();
// === 新增下级(招商→主播数/天,主播→普通用户数/天)===
map.put("todayNewSub", promoTurnoverMapper.countNewSubByDate(userId, childType, todayStart, todayEnd));
map.put("yesterdayNewSub", promoTurnoverMapper.countNewSubByDate(userId, childType, yesterdayStart, yesterdayEnd));
// === 总佣金(新人充值×充值返点比例 + 新游戏消费×新游戏返点 + 经典玩法消费×经典返点)===
BigDecimal hundred = BigDecimal.valueOf(100);
BigDecimal commissionRate = currentUser != null && currentUser.getCommissionRate() != null
? currentUser.getCommissionRate().divide(hundred, 6, BigDecimal.ROUND_HALF_UP) : BigDecimal.ZERO;
BigDecimal newGameRate = currentUser != null && currentUser.getNewGameCommissionRate() != null
? currentUser.getNewGameCommissionRate().divide(hundred, 6, BigDecimal.ROUND_HALF_UP) : BigDecimal.ZERO;
BigDecimal classicGameRate = currentUser != null && currentUser.getClassicGameCommissionRate() != null
? currentUser.getClassicGameCommissionRate().divide(hundred, 6, BigDecimal.ROUND_HALF_UP) : BigDecimal.ZERO;
BigDecimal todayCommission = BigDecimal.ZERO;
BigDecimal yesterdayCommission = BigDecimal.ZERO;
if (CollectionUtils.isNotEmpty(allSubUserIds)) {
// === 佣金数据:一次查询替代原来6次(充值×2 + 新游戏×2 + 经典×2)===
Map<String, Object> commData = promoTurnoverMapper.getBatchedCommissionData(
allSubUserIds, gameUserIds, todayStart, todayEnd, yesterdayStart, yesterdayEnd);
BigDecimal todaySubRecharge = toBd(commData, "todayRecharge");
BigDecimal yesterdaySubRecharge = toBd(commData, "yesterdayRecharge");
BigDecimal todayNewGameConsume = toBd(commData, "todayNewGame");
BigDecimal yesterdayNewGameConsume = toBd(commData, "yesterdayNewGame");
BigDecimal todayClassicConsume = toBd(commData, "todayClassic");
BigDecimal yesterdayClassicConsume = toBd(commData, "yesterdayClassic");
todayCommission = commissionRate.multiply(todaySubRecharge)
.add(newGameRate.multiply(todayNewGameConsume))
.add(classicGameRate.multiply(todayClassicConsume));
yesterdayCommission = commissionRate.multiply(yesterdaySubRecharge)
.add(newGameRate.multiply(yesterdayNewGameConsume))
.add(classicGameRate.multiply(yesterdayClassicConsume));
// === 下级充值(复用上面的批量结果)===
map.put("todaySubRecharge", todaySubRecharge);
map.put("yesterdaySubRecharge", yesterdaySubRecharge);
// === 所有下级总充值(终身累计,时间边界传 null)===
BigDecimal totalSubRecharge = promoTurnoverMapper.getSubTotalRecharge(allSubUserIds, null, null);
map.put("totalSubRecharge", totalSubRecharge != null ? totalSubRecharge : BigDecimal.ZERO);
// === 下级收益(所有下级用户的提货总价值)===
BigDecimal todaySubIncome = promoTurnoverMapper.getSubDeliveryValueByDate(allSubUserIds, todayStart, todayEnd);
BigDecimal yesterdaySubIncome = promoTurnoverMapper.getSubDeliveryValueByDate(allSubUserIds, yesterdayStart, yesterdayEnd);
map.put("todaySubIncome", todaySubIncome != null ? todaySubIncome : BigDecimal.ZERO);
map.put("yesterdaySubIncome", yesterdaySubIncome != null ? yesterdaySubIncome : BigDecimal.ZERO);
// 查询名下主播数
map.put("anchorCount", promoTurnoverMapper.getAnchorCount(userId));
// === 消费流水:招商=下级主播+主播下级(普通用户);主播=下级普通用户,均取 allSubUserIds ===
Map<String, Object> batched = promoTurnoverMapper.getBatchedTurnover(
allSubUserIds, new ArrayList<>(TtAccountRecordSource.getWelfarePrizeCodes()));
if (batched != null) {
map.put("totalTurnover", batched.get("totalTurnover"));
map.put("lastMonthTurnover", batched.get("lastMonth"));
map.put("currentMonthTurnover", batched.get("currentMonth"));
map.put("lastWeekTurnover", batched.get("lastWeek"));
map.put("currentWeekTurnover", batched.get("currentWeek"));
map.put("lastDayTurnover", batched.get("lastDay"));
map.put("currentDayTurnover", batched.get("currentDay"));
} else {
map.put("totalTurnover", BigDecimal.ZERO);
map.put("lastMonthTurnover", BigDecimal.ZERO);
map.put("currentMonthTurnover", BigDecimal.ZERO);
map.put("lastWeekTurnover", BigDecimal.ZERO);
map.put("currentWeekTurnover", BigDecimal.ZERO);
map.put("lastDayTurnover", BigDecimal.ZERO);
map.put("currentDayTurnover", BigDecimal.ZERO);
}
} else {
map.put("anchorCount", 0);
map.put("totalTurnover", BigDecimal.ZERO);
map.put("lastMonthTurnover", BigDecimal.ZERO);
map.put("currentMonthTurnover", BigDecimal.ZERO);
map.put("lastWeekTurnover", BigDecimal.ZERO);
map.put("currentWeekTurnover", BigDecimal.ZERO);
map.put("lastDayTurnover", BigDecimal.ZERO);
map.put("currentDayTurnover", BigDecimal.ZERO);
map.put("todaySubRecharge", BigDecimal.ZERO);
map.put("yesterdaySubRecharge", BigDecimal.ZERO);
map.put("totalSubRecharge", BigDecimal.ZERO);
map.put("todaySubIncome", BigDecimal.ZERO);
map.put("yesterdaySubIncome", BigDecimal.ZERO);
}
map.put("todayCommission", todayCommission);
map.put("yesterdayCommission", yesterdayCommission);
// === 个人消费福利:一次查询替代原来2次(today + yesterday)===
Map<String, Object> welfare = promoTurnoverMapper.getBatchedWelfare(
userId, todayStart, todayEnd, yesterdayStart, yesterdayEnd);
map.put("todayPersonalWelfare", toBd(welfare, "todayWelfare"));
map.put("yesterdayPersonalWelfare", toBd(welfare, "yesterdayWelfare"));
return AjaxResult.success(map);
}
@Override
public AjaxResult getGameCommissionData(Integer userId) {
TtUser currentUser = userMapper.selectById(userId);
String childType = UserType.COMMON_USER.getCode();
boolean isBusiness = false;
if (currentUser != null) {
if (UserType.BUSINESS_USER.getCode().equals(currentUser.getUserType())) {
childType = UserType.ANCHOR.getCode();
isBusiness = true;
} else if (UserType.ANCHOR.getCode().equals(currentUser.getUserType())) {
childType = UserType.COMMON_USER.getCode();
}
}
List<Integer> allSubUserIds = new ArrayList<>();
getAllSubUserIdsRecursive(userId, allSubUserIds);
List<Integer> gameUserIds = allSubUserIds;
if (isBusiness && !allSubUserIds.isEmpty()) {
Set<Integer> anchorIds = new LambdaQueryChainWrapper<>(userMapper)
.in(TtUser::getUserId, allSubUserIds)
.eq(TtUser::getUserType, UserType.ANCHOR.getCode())
.list()
.stream().map(TtUser::getUserId).collect(Collectors.toSet());
gameUserIds = allSubUserIds.stream()
.filter(id -> !anchorIds.contains(id))
.collect(Collectors.toList());
}
Date todayStart = getStartOfToday().getTime();
Date todayEnd = new Date(todayStart.getTime() + 86400000L);
Date yesterdayStart = getStartOfYesterday().getTime();
Date yesterdayEnd = new Date(yesterdayStart.getTime() + 86400000L);
Date[] weekRange = getCurrentWeekRange();
Date weekStart = weekRange[0];
Date weekEnd = weekRange[1];
Map<String, Object> result = new LinkedHashMap<>();
// 当前登录用户的后台返佣比例
Map<String, Object> rates = new LinkedHashMap<>();
rates.put("rechargeRate", currentUser != null && currentUser.getCommissionRate() != null
? currentUser.getCommissionRate() : BigDecimal.ZERO);
rates.put("newGameRate", currentUser != null && currentUser.getNewGameCommissionRate() != null
? currentUser.getNewGameCommissionRate() : BigDecimal.ZERO);
rates.put("classicGameRate", currentUser != null && currentUser.getClassicGameCommissionRate() != null
? currentUser.getClassicGameCommissionRate() : BigDecimal.ZERO);
result.put("rates", rates);
// 各游戏消费及充值数据
Map<String, Object> data;
if (CollectionUtils.isEmpty(allSubUserIds)) {
data = createZeroGameCommissionData();
} else {
List<Integer> effectiveGameUserIds = gameUserIds.isEmpty()
? Collections.singletonList(-1) : gameUserIds;
data = promoTurnoverMapper.getGameCommissionData(
effectiveGameUserIds,
allSubUserIds,
todayStart, todayEnd,
yesterdayStart, yesterdayEnd,
weekStart, weekEnd);
}
// 新增下级
int todayNewSub = promoTurnoverMapper.countNewSubByDate(userId, childType, todayStart, todayEnd);
int yesterdayNewSub = promoTurnoverMapper.countNewSubByDate(userId, childType, yesterdayStart, yesterdayEnd);
int weekNewSub = promoTurnoverMapper.countNewSubByDate(userId, childType, weekStart, weekEnd);
int totalNewSub = CollectionUtils.isEmpty(allSubUserIds) ? 0 : allSubUserIds.size();
BigDecimal rechargeRate = toBd(rates, "rechargeRate");
BigDecimal newGameRate = toBd(rates, "newGameRate");
BigDecimal classicGameRate = toBd(rates, "classicGameRate");
// 按周期组装结果:今日/昨日/本周/总
List<Map<String, Object>> periods = new ArrayList<>();
periods.add(buildPeriod("今日", data,
rechargeRate, newGameRate, classicGameRate,
"todayClassicBox", "todayLuckyUpgrade", "todayBattle", "todayCrazyWheel", "todayOlympus",
"todayRecharge", todayNewSub));
periods.add(buildPeriod("昨日", data,
rechargeRate, newGameRate, classicGameRate,
"yesterdayClassicBox", "yesterdayLuckyUpgrade", "yesterdayBattle", "yesterdayCrazyWheel", "yesterdayOlympus",
"yesterdayRecharge", yesterdayNewSub));
periods.add(buildPeriod("本周", data,
rechargeRate, newGameRate, classicGameRate,
"weekClassicBox", "weekLuckyUpgrade", "weekBattle", "weekCrazyWheel", "weekOlympus",
"weekRecharge", weekNewSub));
periods.add(buildPeriod("总", data,
rechargeRate, newGameRate, classicGameRate,
"totalClassicBox", "totalLuckyUpgrade", "totalBattle", "totalCrazyWheel", "totalOlympus",
"totalRecharge", totalNewSub));
result.put("periods", periods);
return AjaxResult.success(result);
}
@Override
public AjaxResult getLast10DaysPromotionData(Integer userId) {
// 分离收集下级:主播(01) 用于"主播日流水/主播日邀请",普通用户(02) 用于"主播下级日流水/主播下级日邀请"
List<Integer> anchorIds = new ArrayList<>();
List<Integer> subUserIds = new ArrayList<>();
collectAnchorAndSubIds(userId, anchorIds, subUserIds);
if (CollectionUtils.isEmpty(anchorIds) && CollectionUtils.isEmpty(subUserIds)) {
return AjaxResult.success(new ArrayList<>());
}
List<Integer> sourceCodes = new ArrayList<>(TtAccountRecordSource.getWelfarePrizeCodes());
Map<String, BigDecimal> anchorTurnoverMap = CollectionUtils.isEmpty(anchorIds)
? Collections.emptyMap()
: toTurnoverDayMap(promoTurnoverMapper.getLast10DaysTurnover(anchorIds, sourceCodes));
Map<String, BigDecimal> subTurnoverMap = CollectionUtils.isEmpty(subUserIds)
? Collections.emptyMap()
: toTurnoverDayMap(promoTurnoverMapper.getLast10DaysTurnover(subUserIds, sourceCodes));
Map<String, Integer> anchorInviteMap = CollectionUtils.isEmpty(anchorIds)
? Collections.emptyMap()
: toInviteDayMap(promoTurnoverMapper.getLast10DaysInviteByUserIds(anchorIds));
Map<String, Integer> subInviteMap = CollectionUtils.isEmpty(subUserIds)
? Collections.emptyMap()
: toInviteDayMap(promoTurnoverMapper.getLast10DaysInviteByUserIds(subUserIds));
// 各查询均为近10天 date_range 左连接,返回同一组日期;合并后升序作为横轴
Set<String> dates = new TreeSet<>();
dates.addAll(anchorTurnoverMap.keySet());
dates.addAll(subTurnoverMap.keySet());
dates.addAll(anchorInviteMap.keySet());
dates.addAll(subInviteMap.keySet());
List<Map<String, Object>> list = new ArrayList<>();
for (String date : dates) {
Map<String, Object> map = new HashMap<>();
map.put("date", date);
map.put("anchorDayTurnover", anchorTurnoverMap.getOrDefault(date, BigDecimal.ZERO));
map.put("subDayTurnover", subTurnoverMap.getOrDefault(date, BigDecimal.ZERO));
map.put("anchorDayInvite", anchorInviteMap.getOrDefault(date, 0));
map.put("subDayInvite", subInviteMap.getOrDefault(date, 0));
list.add(map);
}
return AjaxResult.success(list);
}
/**
* 递归分离收集下级用户ID:
* 主播(01) 归入 anchorIds 并继续下钻,普通用户(02) 归入 subUserIds。
*/
private void collectAnchorAndSubIds(int parentId, List<Integer> anchorIds, List<Integer> subUserIds) {
List<TtUser> users = promoTurnoverMapper.findByParentId(parentId);
for (TtUser user : users) {
if (UserType.ANCHOR.getCode().equals(user.getUserType())) {
anchorIds.add(user.getUserId());
collectAnchorAndSubIds(user.getUserId(), anchorIds, subUserIds);
} else if (UserType.COMMON_USER.getCode().equals(user.getUserType())) {
subUserIds.add(user.getUserId());
}
}
}
private Map<String, BigDecimal> toTurnoverDayMap(List<DayTurnoverVO> list) {
Map<String, BigDecimal> map = new HashMap<>();
if (list == null) return map;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (DayTurnoverVO vo : list) {
map.put(sdf.format(vo.getDate()), vo.getAmount());
}
return map;
}
private Map<String, Integer> toInviteDayMap(List<DayInviteVO> list) {
Map<String, Integer> map = new HashMap<>();
if (list == null) return map;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (DayInviteVO vo : list) {
map.put(sdf.format(vo.getDate()), vo.getAnchorCount());
}
return map;
}
@Override
public List<AnchorDayTurnoverVO> getAnchorDayTurnover(AnchorDayTurnoverDTO anchorDayTurnoverDTO) {
// 递归名下所有玩家
Integer userId = anchorDayTurnoverDTO.getUserId();
List<TtUser> allPlayer = recursionAllPlayer(userId);
List<Integer> userIds = new ArrayList<>();
for (TtUser ttUser : allPlayer) {
userIds.add(ttUser.getUserId());
}
if (CollectionUtils.isEmpty(userIds)) {
return Collections.emptyList();
}
anchorDayTurnoverDTO.setUserIds(userIds);
anchorDayTurnoverDTO.setSourceCodes(new ArrayList<>(TtAccountRecordSource.getWelfarePrizeCodes()));
List<AnchorDayTurnoverVO> result = promoTurnoverMapper.getAnchorDayTurnover(anchorDayTurnoverDTO);
for (AnchorDayTurnoverVO anchorDayTurnoverVO : result) {
Date startDate = anchorDayTurnoverVO.getDate();
Date endDate = DateTimeUtils.addDays(startDate, 1);
BigDecimal dailyTotalRecharge = promoTurnoverMapper.getDailyTotalRecharge(anchorDayTurnoverVO.getUserId(), startDate, endDate);
anchorDayTurnoverVO.setDailyRechargeTotal(dailyTotalRecharge == null ? BigDecimal.ZERO : dailyTotalRecharge);
}
return result;
}
@Override
public GetSubBranchesResponse getSubBranches(GetSubBranchesRequest request) {
if (request.getPageNum() == null || request.getPageNum() <= 0) {
request.setPageNum(1);
}
if (request.getPageSize() == null || request.getPageSize() <= 0) {
request.setPageSize(10);
}
Long userId = Long.valueOf(request.getUserId());
var query = new LambdaQueryChainWrapper<>(userMapper).eq(TtUser::getParentId, userId);
if (request.getPlayerId() != null) {
query.eq(TtUser::getUserId, request.getPlayerId());
}
Page<TtUser> page = new Page<>(request.getPageNum(), request.getPageSize());
page.setOptimizeCountSql(false);
var pageData = query.page(page);
var childrenList = pageData.getRecords();
if (CollectionUtils.isEmpty(childrenList)) {
GetSubBranchesResponse response = new GetSubBranchesResponse();
response.setTotal((int) page.getTotal());
response.setRows(new ArrayList<>());
return response;
}
List<Integer> userids = childrenList.stream().map(TtUser::getUserId).collect(Collectors.toList());
List<TtUserBlendErcash> ercashList = new LambdaQueryChainWrapper<>(userBlendErcashMapper)
.in(TtUserBlendErcash::getSource, TtAccountRecordSource.getWelfarePrizeCodes())
.in(TtUserBlendErcash::getUserId, userids).list();
Map<Integer, List<TtUserBlendErcash>> ercashMap = ercashList.stream()
.collect(Collectors.groupingBy(TtUserBlendErcash::getUserId));
List<GetSubBranchesResponse.UserInfo> resultList = childrenList.stream().map(user -> {
GetSubBranchesResponse.UserInfo userInfo = new GetSubBranchesResponse.UserInfo();
BeanUtils.copyProperties(user, userInfo);
userInfo.setTotalCost(BigDecimal.ZERO);
userInfo.setTotalRecharge(BigDecimal.ZERO);
if (ercashMap.containsKey(user.getUserId())) {
var records = ercashMap.get(user.getUserId());
var totalRecharge = records.stream().filter(v -> {
return Objects.equals(v.getSource(), TtAccountRecordSource.RECHARGE.getCode());
}).map(v -> v.getAmount().abs()).reduce(BigDecimal.ZERO, BigDecimal::add);
var allConsumeList = records.stream().filter(v -> {
return Objects.equals(v.getType(), TtAccountRecordType.OUTPUT.getCode());
}).toList();
var totalNormalGameConsume = allConsumeList.stream().filter(v -> {
return TtAccountRecordSource.isNormalGameConsume(v.getSource());
}).map(v -> v.getAmount().abs()).reduce(BigDecimal.ZERO, BigDecimal::add);
var totalSpecialGameConsume = allConsumeList.stream().filter(v -> {
return TtAccountRecordSource.isSpecialGameConsume(v.getSource());
}).map(v -> v.getAmount().abs()).reduce(BigDecimal.ZERO, BigDecimal::add);
userInfo.setTotalNormGameCost(totalNormalGameConsume);
userInfo.setTotalSpecialGameCost(totalSpecialGameConsume);
userInfo.setTotalRecharge(totalRecharge);
}
return userInfo;
}).collect(Collectors.toList());
GetSubBranchesResponse response = new GetSubBranchesResponse();
response.setTotal((int) page.getTotal());
response.setRows(resultList);
return response;
}
private Set<TtUser> findAllChild(Integer parentId, Set<TtUser> result) {
List<TtUser> list = promoTurnoverMapper.findByParentId(parentId);
HashSet<TtUser> childrens = new HashSet<>(list);
if (childrens.isEmpty()) return result;
result.addAll(childrens);
for (TtUser child : childrens) {
Set<TtUser> allChild = findAllChild(child.getUserId(), result);
result.addAll(allChild);
}
return result;
}
@Override
public TableDataInfo getPurchaseByUserId(GetPurchaseByUserIdRequest request) {
if (request.getPageNum() == null || request.getPageNum() <= 0) {
request.setPageNum(1);
}
if (request.getPageSize() == null || request.getPageSize() <= 0) {
request.setPageSize(10);
}
if (request.getBeginTime() == null) {
LocalDateTime endTime = LocalDateTime.now();
LocalDateTime startTime = endTime.minusDays(30).with(LocalTime.MIN);
request.setBeginTime(DateTimeUtils.date(startTime));
}
if (request.getEndTime() == null) {
LocalDateTime endTime = LocalDateTime.now();
request.setEndTime(DateTimeUtils.date(endTime));
}
// Limit the time range to maximum 30 days
if (request.getBeginTime() != null && request.getEndTime() != null) {
LocalDateTime begin = DateTimeUtils.toLocalDateTime(DateTimeUtils.dateTime(DateTimeUtils.YYYY_MM_DD, request.getBeginTime()));
LocalDateTime end = DateTimeUtils.toLocalDateTime(DateTimeUtils.dateTime(DateTimeUtils.YYYY_MM_DD, request.getEndTime()));
if (ChronoUnit.DAYS.between(begin, end) > 30) {
LocalDateTime adjustedEnd = begin.plusDays(30);
request.setEndTime(DateTimeUtils.date(adjustedEnd));
}
}
// 权限校验:只能查询自己下级的流水
Long currentUserId = SecurityUtils.getUserId();
Integer queryUserId = request.getUserId();
// 招商账号:可以查所有下级(递归)
// 主播账号:只能查自己或自己的直属下级
TtUser currentUser = userMapper.selectById(currentUserId.intValue());
boolean isBusiness = currentUser != null && UserType.BUSINESS_USER.getCode().equals(currentUser.getUserType());
if (!isBusiness) {
// 主播账号:只能查自己
if (!queryUserId.equals(currentUserId.intValue())) {
var result = new TableDataInfo();
result.setCode(HttpStatus.ERROR);
result.setMsg("无权查看他人数据");
return result;
}
} else {
// 招商账号:需要校验 queryUserId 是否属于当前招商的下级树
List<Integer> allSubUserIds = new ArrayList<>();
getAllSubUserIdsRecursive(currentUserId.intValue(), allSubUserIds);
if (!allSubUserIds.contains(queryUserId)) {
var result = new TableDataInfo();
result.setCode(HttpStatus.ERROR);
result.setMsg("无权查看该用户数据");
return result;
}
}
List<String> days = DateTimeUtils.generateDailyDates(request.getBeginTime(), request.getEndTime());
days.sort(Comparator.reverseOrder());
int total = days.size();
int start = Math.min((request.getPageNum() - 1) * request.getPageSize(), total - 1);
int end = Math.min(start + request.getPageSize(), total);
start = Math.max(start, 0);
end = Math.max(end, 0);
days = days.subList(start, end);
List<DailyUserErcashVO> resultList = days.stream().map(d -> {
var records = new LambdaQueryChainWrapper<>(userBlendErcashMapper)
.eq(TtUserBlendErcash::getUserId, request.getUserId())
.apply("DATE(create_time) = {0}", d)
.in(TtUserBlendErcash::getSource, TtAccountRecordSource.getWelfarePrizeCodes())
.list();
var totalRecharge = records.stream().filter(v -> {
return Objects.equals(v.getSource(), TtAccountRecordSource.RECHARGE.getCode());
}).map(v -> v.getAmount().abs()).reduce(BigDecimal.ZERO, BigDecimal::add);
var allConsumeList = records.stream().filter(v -> {
return Objects.equals(v.getType(), TtAccountRecordType.OUTPUT.getCode());
}).toList();
var totalNormalGameConsume = allConsumeList.stream().filter(v -> {
return TtAccountRecordSource.isNormalGameConsume(v.getSource());
}).map(v -> v.getAmount().abs()).reduce(BigDecimal.ZERO, BigDecimal::add);
var totalSpecialGameConsume = allConsumeList.stream().filter(v -> {
return TtAccountRecordSource.isSpecialGameConsume(v.getSource());
}).map(v -> v.getAmount().abs()).reduce(BigDecimal.ZERO, BigDecimal::add);
DailyUserErcashVO r = new DailyUserErcashVO();
r.setDate(d);
r.setDailyNormGameCost(totalNormalGameConsume);
r.setDailySpecialGameCost(totalSpecialGameConsume);
r.setDailyRecharge(totalRecharge);
return r;
}).collect(Collectors.toList());
TableDataInfo result = new TableDataInfo();
result.setCode(200);
result.setRows(resultList);
result.setTotal(total);
return result;
}
@Override
public BigDecimal getCommissionRateByUserId(Integer userId) {
return promoTurnoverMapper.getCommissionRateByUserId(userId);
}
@Override
public int updateCommissionRate(Integer userId, BigDecimal commissionRate) {
return promoTurnoverMapper.updateCommissionRate(userId, commissionRate);
}
@Override
public List<TtCommissionRecord> getCommissionList(Integer userId) {
return promoTurnoverMapper.getCommissionList(userId);
}
@Override
public QueryPromotionWelfareResponse queryPromoteWelfare(Integer userId, QueryPromoteWelfareRequest request) {
Page<TtUserBlendErcash> page = new Page<>(request.getPageNum(), request.getPageSize());
page.setOptimizeCountSql(false);
LambdaQueryChainWrapper<TtUserBlendErcash> wrapper = new LambdaQueryChainWrapper<>(userBlendErcashMapper)
.eq(TtUserBlendErcash::getUserId, userId)
.eq(TtUserBlendErcash::getSource, TtAccountRecordSource.P_WELFARE.getCode());
if (request.getBeginTime() != null) {
wrapper.ge(TtUserBlendErcash::getCreateTime, request.getBeginTime());
}
if (request.getEndTime() != null) {
wrapper.le(TtUserBlendErcash::getCreateTime, request.getEndTime());
}
wrapper.orderByDesc(TtUserBlendErcash::getCreateTime);
Page<TtUserBlendErcash> pageResult = wrapper.page(page);
BigDecimal totalReward = promoTurnoverMapper.queryTotalRewardBySource(userId, TtAccountRecordSource.P_WELFARE.getCode());
if (totalReward == null) {
totalReward = BigDecimal.ZERO;
}
if (CollectionUtils.isEmpty(pageResult.getRecords())) {
QueryPromotionWelfareResponse result = new QueryPromotionWelfareResponse();
result.setRows(new ArrayList<>());
result.setTotal(0);
result.setTotalReward(totalReward);
return result;
}
QueryPromotionWelfareResponse result = new QueryPromotionWelfareResponse();
result.setRows(pageResult.getRecords().stream().map(v -> {
QueryPromotionWelfareResponse.Record r = new QueryPromotionWelfareResponse.Record();
r.setReward(v.getAmount());
r.setDate(DateTimeUtils.dateTime(v.getCreateTime().toLocalDateTime()));
return r;
}).collect(Collectors.toList()));
result.setTotalReward(totalReward);
result.setTotal((int) pageResult.getTotal());
return result;
}
/**
* 递归下级分支
*/
private void buildUserTree(TtUser ttUser) {
// 获取当前用户的子节点
List<TtUser> children = promoTurnoverMapper.findByParentId(ttUser.getUserId());
// 设置当前用户的子节点
ttUser.setChildren(children);
for (TtUser child : children) {
// 如果子节点是主播,继续递归
if ("01".equals(child.getUserType())) {
buildUserTree(child);
}
}
}
@Override
public List<TtUser> recursionAllPlayer(Integer userId) {
List<TtUser> players = new ArrayList<>();
getAllPlayersByAnchorIdRecursive(userId, players);
return players;
}
/**
* 递归名下所有玩家(仅普通用户type=02)
*/
private void getAllPlayersByAnchorIdRecursive(int parentId, List<TtUser> players) {
List<TtUser> users = promoTurnoverMapper.findByParentId(parentId);
for (TtUser user : users) {
if ("02".equals(user.getUserType())) { // 如果是玩家
players.add(user);
} else if ("01".equals(user.getUserType())) { // 如果是主播,继续递归
getAllPlayersByAnchorIdRecursive(user.getUserId(), players);
}
}
}
/**
* 递归获取所有下级用户ID(包含主播和普通用户)
* 招商→主播+普通用户,主播→普通用户
*/
private void getAllSubUserIdsRecursive(int parentId, List<Integer> subUserIds) {
List<TtUser> users = promoTurnoverMapper.findByParentId(parentId);
for (TtUser user : users) {
subUserIds.add(user.getUserId());
if ("01".equals(user.getUserType())) { // 如果是主播,继续递归其下级
getAllSubUserIdsRecursive(user.getUserId(), subUserIds);
}
}
}
@Override
public void getAllSubIdsRecursive(Integer parentId, List<Integer> subUserIds) {
getAllSubUserIdsRecursive(parentId, subUserIds);
}
private Calendar getStartOfLastMonth() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MONTH, -1);
calendar.set(Calendar.DAY_OF_MONTH, 1);
resetTime(calendar);
return calendar;
}
private Calendar getEndOfLastMonth() {
Calendar calendar = getStartOfLastMonth();
calendar.add(Calendar.MONTH, 1);
calendar.add(Calendar.DAY_OF_MONTH, -1);
setTimeToEndOfDay(calendar);
return calendar;
}
private Calendar getStartOfThisMonth() {
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.DAY_OF_MONTH, 1);
resetTime(calendar);
return calendar;
}
private Calendar getStartOfLastWeek() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.WEEK_OF_YEAR, -1);
calendar.set(Calendar.DAY_OF_WEEK, calendar.getFirstDayOfWeek());
resetTime(calendar);
return calendar;
}
private Calendar getEndOfLastWeek() {
Calendar calendar = getStartOfLastWeek();
calendar.add(Calendar.DAY_OF_WEEK, 6);
setTimeToEndOfDay(calendar);
return calendar;
}
private Calendar getStartOfThisWeek() {
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.DAY_OF_WEEK, calendar.getFirstDayOfWeek());
resetTime(calendar);
return calendar;
}
private Calendar getStartOfYesterday() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, -1);
resetTime(calendar);
return calendar;
}
private Calendar getEndOfYesterday() {
Calendar calendar = getStartOfYesterday();
setTimeToEndOfDay(calendar);
return calendar;
}
private Calendar getStartOfToday() {
Calendar calendar = Calendar.getInstance();
resetTime(calendar);
return calendar;
}
private void resetTime(Calendar calendar) {
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
}
private void setTimeToEndOfDay(Calendar calendar) {
calendar.set(Calendar.HOUR_OF_DAY, 23);
calendar.set(Calendar.MINUTE, 59);
calendar.set(Calendar.SECOND, 59);
calendar.set(Calendar.MILLISECOND, 999);
}
/** 从 Map 中安全取 BigDecimal,null 返回 ZERO */
private BigDecimal toBd(Map<String, Object> map, String key) {
if (map == null) return BigDecimal.ZERO;
Object v = map.get(key);
return v instanceof BigDecimal ? (BigDecimal) v : BigDecimal.ZERO;
}
/**
* 获取本周一 00:00:00 到下周周一 00:00:00(左闭右开),与 weeklyDailyCommission 口径一致。
*/
private Date[] getCurrentWeekRange() {
Calendar cal = Calendar.getInstance();
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
int daysSinceMonday = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7;
cal.add(Calendar.DAY_OF_MONTH, -daysSinceMonday);
Date start = cal.getTime();
cal.add(Calendar.DAY_OF_MONTH, 7);
Date end = cal.getTime();
return new Date[]{start, end};
}
/**
* 构造无下级时的全零看板数据。
*/
private Map<String, Object> createZeroGameCommissionData() {
Map<String, Object> map = new HashMap<>();
String[] keys = {
"todayClassicBox", "todayLuckyUpgrade", "todayBattle", "todayCrazyWheel", "todayOlympus",
"yesterdayClassicBox", "yesterdayLuckyUpgrade", "yesterdayBattle", "yesterdayCrazyWheel", "yesterdayOlympus",
"weekClassicBox", "weekLuckyUpgrade", "weekBattle", "weekCrazyWheel", "weekOlympus",
"totalClassicBox", "totalLuckyUpgrade", "totalBattle", "totalCrazyWheel", "totalOlympus",
"todayRecharge", "yesterdayRecharge", "weekRecharge", "totalRecharge"
};
for (String key : keys) {
map.put(key, BigDecimal.ZERO);
}
return map;
}
/**
* 组装单个周期的看板数据行。
*/
private Map<String, Object> buildPeriod(String periodName, Map<String, Object> data,
BigDecimal rechargeRate, BigDecimal newGameRate, BigDecimal classicGameRate,
String classicBoxKey, String luckyUpgradeKey, String battleKey,
String crazyWheelKey, String olympusKey, String rechargeKey, int newSub) {
BigDecimal classicBox = toBd(data, classicBoxKey);
BigDecimal luckyUpgrade = toBd(data, luckyUpgradeKey);
BigDecimal battle = toBd(data, battleKey);
BigDecimal crazyWheel = toBd(data, crazyWheelKey);
BigDecimal olympus = toBd(data, olympusKey);
BigDecimal recharge = toBd(data, rechargeKey);
BigDecimal totalConsume = classicBox.add(luckyUpgrade).add(battle).add(crazyWheel).add(olympus);
BigDecimal classicConsume = classicBox.add(luckyUpgrade).add(battle).add(crazyWheel);
BigDecimal totalRebate = calcCommission(recharge, rechargeRate)
.add(calcCommission(olympus, newGameRate))
.add(calcCommission(classicConsume, classicGameRate));
Map<String, Object> period = new LinkedHashMap<>();
period.put("period", periodName);
period.put("classicBox", classicBox);
period.put("luckyUpgrade", luckyUpgrade);
period.put("battle", battle);
period.put("crazyWheel", crazyWheel);
period.put("olympus", olympus);
period.put("totalConsume", totalConsume);
period.put("recharge", recharge);
period.put("newSub", newSub);
period.put("totalRebate", totalRebate);
return period;
}
/**
* 按百分比费率计算佣金:amount × rate / 100,保留两位小数。
*/
private BigDecimal calcCommission(BigDecimal amount, BigDecimal rate) {
if (amount == null || rate == null
|| amount.compareTo(BigDecimal.ZERO) <= 0
|| rate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
return amount.multiply(rate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
}
@@ -0,0 +1,233 @@
package com.ruoyi.promo.task;
import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.domain.common.constant.MessageCategoryEnum;
import com.ruoyi.domain.common.constant.TtAccountRecordSource;
import com.ruoyi.domain.entity.TtCommissionRecord;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.promo.mapper.PromoTurnoverMapper;
import com.ruoyi.message.service.MessageService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 招商佣金周结定时任务。
*
* 注册到 RuoYi 系统监控-定时任务:
* - job name: 招商佣金周结
* job group: PROMO
* invoke target: commissionSettlementTask.settleWeeklyCommission()
* cron: 0 30 0 ? * MON (每周一 00:30)
*/
@Slf4j
@Component("commissionSettlementTask")
public class CommissionSettlementTask {
@Autowired
private PromoTurnoverMapper promoTurnoverMapper;
@Autowired
private TtUserService userService;
@Autowired
private MessageService messageService;
@Autowired
private PlatformTransactionManager transactionManager;
/**
* 执行每周佣金结算。
* 结算周期:上周一 00:00 ~ 本周一 00:00
*
* 每个招商账号在独立事务中结算:结算记录插入与佣金入账同成败,
* 入账失败时回滚该用户的记录,下次任务可重试(幂等检查不会误跳),
* 同时避免单个用户异常影响其他用户结算。
*/
public void settleWeeklyCommission() {
log.info("[招商佣金周结] 任务开始 ----------");
Date[] weekRange = getLastWeekRange();
Date startTime = weekRange[0];
Date endTime = weekRange[1];
Date now = new Date();
log.info("[招商佣金周结] 结算周期: {} ~ {}",
DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", startTime),
DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", endTime));
List<TtUser> businessUsers = promoTurnoverMapper.getBusinessUsers();
if (CollectionUtils.isEmpty(businessUsers)) {
log.info("[招商佣金周结] 没有招商账号,任务结束");
return;
}
int successCount = 0;
int skipCount = 0;
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
for (TtUser businessUser : businessUsers) {
try {
// 每个用户独立事务:失败仅回滚自己,不影响其他用户,且可在下次任务重试
Boolean settled = txTemplate.execute(status ->
settleSingleBusinessUser(businessUser, startTime, endTime, now));
if (Boolean.TRUE.equals(settled)) {
successCount++;
} else {
skipCount++;
}
} catch (Exception e) {
log.error("[招商佣金周结] 用户ID={} 结算异常,已回滚本用户结算", businessUser.getUserId(), e);
}
}
log.info("[招商佣金周结] 任务完成,成功={}, 跳过={} ----------", successCount, skipCount);
}
/**
* 结算单个招商账号。
*
* @return true 本次产生新结算;false 因幂等已存在而跳过
*/
private boolean settleSingleBusinessUser(TtUser businessUser, Date startTime, Date endTime, Date now) {
Integer userId = businessUser.getUserId();
// 幂等:同一周期已结算则跳过
int existed = promoTurnoverMapper.countCommissionRecord(userId, startTime);
if (existed > 0) {
log.info("[招商佣金周结] userId={} 本周期已结算,跳过", userId);
return false;
}
BigDecimal commission = BigDecimal.ZERO;
BigDecimal totalFlow = BigDecimal.ZERO;
// 按 business_id 快照统计上周流水:改绑/解绑不影响已发生流水的招商归属。
Map<String, Object> data = promoTurnoverMapper.getWeeklyCommissionDataWithSnapshot(
userId, startTime, endTime);
BigDecimal recharge = toBd(data, "recharge");
BigDecimal newGameConsume = toBd(data, "newGameConsume");
BigDecimal classicGameConsume = toBd(data, "classicGameConsume");
BigDecimal commissionRate = businessUser.getCommissionRate() != null
? businessUser.getCommissionRate() : BigDecimal.ZERO;
BigDecimal newGameRate = businessUser.getNewGameCommissionRate() != null
? businessUser.getNewGameCommissionRate() : BigDecimal.ZERO;
BigDecimal classicGameRate = businessUser.getClassicGameCommissionRate() != null
? businessUser.getClassicGameCommissionRate() : BigDecimal.ZERO;
BigDecimal rechargeCommission = calcRate(recharge, commissionRate);
BigDecimal newGameCommission = calcRate(newGameConsume, newGameRate);
BigDecimal classicGameCommission = calcRate(classicGameConsume, classicGameRate);
commission = rechargeCommission.add(newGameCommission).add(classicGameCommission);
totalFlow = recharge.add(newGameConsume).add(classicGameConsume);
// 保存结算记录
TtCommissionRecord record = new TtCommissionRecord();
record.setUserId(userId);
record.setCommission(commission);
record.setSummaryTime(now);
record.setSettlementStartTime(startTime);
record.setSettlementEndTime(endTime);
record.setTotalFlow(totalFlow);
record.setRate(calcCompositeRate(commission, totalFlow));
record.setClaimStatus("1");
record.setClaimTime(now);
promoTurnoverMapper.insertCommissionRecord(record);
// 佣金到账(入账失败必须抛异常回滚结算记录,否则记录存在但钱未到账,
// 且幂等检查会导致永不补发)
if (commission.compareTo(BigDecimal.ZERO) > 0) {
R<?> credited = userService.updateUserAccount(userId, commission, TtAccountRecordSource.BUSINESS_WEEKLY_COMMISSION);
if (R.isError(credited)) {
throw new RuntimeException("佣金入账失败: "
+ (credited.getMsg() != null ? credited.getMsg() : "未知原因"));
}
// 发送站内信通知
String title = MessageCategoryEnum.SYS_PROMO_REBATE.getTitle();
String content = String.format(
"上周(%s ~ %s)招商周结返利已到账,返利 %s,请注意查收。",
DateUtils.parseDateToStr("yyyy-MM-dd", startTime),
DateUtils.parseDateToStr("yyyy-MM-dd", endTime),
commission.toPlainString());
try {
messageService.sendNotice(Long.valueOf(userId), title, content, MessageCategoryEnum.SYS_PROMO_REBATE.getCode());
} catch (Exception e) {
log.error("[招商佣金周结] userId={} 发送到账通知失败", userId, e);
}
}
log.info("[招商佣金周结] userId={} 结算完成,流水={}, 佣金={}", userId, totalFlow, commission);
return true;
}
/**
* 计算上周起止时间:上周一 00:00 ~ 本周一 00:00
*/
private Date[] getLastWeekRange() {
Calendar end = Calendar.getInstance();
resetTime(end);
// 回到本周一 00:00
int dayOfWeek = end.get(Calendar.DAY_OF_WEEK);
int daysSinceMonday = (dayOfWeek + 5) % 7;
end.add(Calendar.DAY_OF_MONTH, -daysSinceMonday);
Calendar start = (Calendar) end.clone();
start.add(Calendar.DAY_OF_MONTH, -7);
return new Date[]{start.getTime(), end.getTime()};
}
private void resetTime(Calendar calendar) {
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
}
/**
* 按百分比比例计算佣金:amount × rate / 100,setScale(2, HALF_UP)。
*/
private BigDecimal calcRate(BigDecimal amount, BigDecimal rate) {
if (amount == null || rate == null
|| amount.compareTo(BigDecimal.ZERO) <= 0
|| rate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
return amount.multiply(rate)
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
/**
* 计算综合费率:commission / totalFlow * 100
*/
private BigDecimal calcCompositeRate(BigDecimal commission, BigDecimal totalFlow) {
if (totalFlow == null || totalFlow.compareTo(BigDecimal.ZERO) <= 0
|| commission == null || commission.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
return commission.divide(totalFlow, 6, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
}
/** 从 Map 中安全取 BigDecimal,null 返回 ZERO */
private BigDecimal toBd(Map<String, Object> map, String key) {
if (map == null) return BigDecimal.ZERO;
Object v = map.get(key);
return v instanceof BigDecimal ? (BigDecimal) v : BigDecimal.ZERO;
}
}
@@ -0,0 +1,92 @@
package com.ruoyi.promo.task;
import com.ruoyi.promo.domain.entity.PromoCdkey;
import com.ruoyi.promo.service.PromoCdkeyService;
import com.ruoyi.message.service.MessageService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* CDK 过期/兑完 精确退还定时任务。
*
* 口径:CDK创建时按 amountMax×num 等额预扣主播金币+积分,兑换时仅随机派发 min~max 金币。
* CDK结束(过期/兑完)后,按「预扣总额 - 实际已派发金币总额」精确退还主播金币与积分(同额)。
*
* 注册到 RuoYi 系统监控-定时任务:
* - job name: CDK结束退还
* job group: PROMO
* invoke target: promoCdkeyRefundTask.settleEndedCdkeyRefunds()
* cron: 0 0/10 * * * ? (每10分钟)
*/
@Slf4j
@Component("promoCdkeyRefundTask")
public class PromoCdkeyRefundTask {
@Autowired
private PromoCdkeyService promoCdkeyService;
@Autowired
private MessageService messageService;
/**
* 扫描已结束(过期/兑完)且未退还的CDK,逐个精确退还,并按主播聚合发送到账通知。
*/
public void settleEndedCdkeyRefunds() {
log.info("[CDK结束退还] 任务开始 ----------");
// 1. 全局补齐过期状态(validity 到期且仍进行中的置为已结束)
int expired = promoCdkeyService.markExpiredCdkeys();
log.info("[CDK结束退还] 补齐过期状态 {} 条", expired);
// 2. 查询待退还候选:status=1 且 refund_time IS NULL
List<PromoCdkey> candidates = promoCdkeyService.selectRefundableCdkeys();
if (CollectionUtils.isEmpty(candidates)) {
log.info("[CDK结束退还] 无待退还CDK,任务结束");
return;
}
// 3. 逐个退还(每个CDK独立事务),按创建者聚合本轮退还总额与数量
Map<Long, BigDecimal> refundByCreator = new HashMap<>();
Map<Long, Integer> countByCreator = new HashMap<>();
int success = 0;
for (PromoCdkey cdkey : candidates) {
try {
BigDecimal refund = promoCdkeyService.refundOne(cdkey);
Long creatorId = cdkey.getCreatorId();
if (refund != null && refund.compareTo(BigDecimal.ZERO) > 0) {
refundByCreator.merge(creatorId, refund, BigDecimal::add);
countByCreator.merge(creatorId, 1, Integer::sum);
}
success++;
} catch (Exception e) {
log.error("[CDK结束退还] cdkeyId={} 退还异常", cdkey.getId(), e);
}
}
// 4. 按主播聚合发送站内信(仅对有实际退还金额的主播,避免刷屏)
// for (Map.Entry<Long, BigDecimal> entry : refundByCreator.entrySet()) {
// Long creatorId = entry.getKey();
// BigDecimal total = entry.getValue();
// int cnt = countByCreator.getOrDefault(creatorId, 0);
// String title = "CDKEY结束退还到账通知";
// String content = String.format(
// "您有 %d 个CDKEY已结束(过期或兑完),共返还金币 %s、积分 %s,请注意查收。",
// cnt, total.toPlainString(), total.toPlainString());
// try {
// messageService.sendNotice(creatorId, title, content);
// } catch (Exception e) {
// log.error("[CDK结束退还] creatorId={} 发送退还通知失败", creatorId, e);
// }
// }
log.info("[CDK结束退还] 任务完成,处理 {} 条,退还主播 {} 个 ----------",
success, refundByCreator.size());
}
}
@@ -0,0 +1,57 @@
package com.ruoyi.promo.task;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.promo.domain.dto.TotalUserExpenditureDTO;
import com.ruoyi.promo.mapper.PromoTurnoverMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.*;
@Slf4j
@Component("PromoTask")
public class PromoTask {
@Autowired
private PromoTurnoverMapper promoTurnoverMapper;
private Map<Integer, BigDecimal> commissionMap = new HashMap<>();
/**
* 计算上月佣金
*/
public void calculateLastMonthCommission() {
// 汇总所有用户上个月的充值总额
List<TotalUserExpenditureDTO> lastMonthTotalUserExpenditure = promoTurnoverMapper.getLastMonthTotalUserExpenditure();
for (TotalUserExpenditureDTO totalUserExpenditureDTO : lastMonthTotalUserExpenditure) {
// 向上递归给各级代理分配佣金
if (!Objects.isNull(totalUserExpenditureDTO.getParentId())) {
distributeCommission(totalUserExpenditureDTO.getParentId(),
totalUserExpenditureDTO.getCommissionRate(),
totalUserExpenditureDTO.getAmount());
}
}
}
/**
* 分配佣金
*/
private void distributeCommission(Integer parentId,
BigDecimal commissionRate,
BigDecimal amount) {
TtUser ttUser = promoTurnoverMapper.findById(parentId);
BigDecimal parentCommissionRate = ttUser.getCommissionRate();
BigDecimal commission = parentCommissionRate.multiply(amount).subtract(commissionRate.multiply(amount));
System.err.println("代理:" + parentId + ",佣金比例:" + parentCommissionRate + ",获得佣金:" + commission);
// 计算并存储该级代理的佣金
// 将相同ID的代理佣金相加
// 判断是否还有上级代理,有继续递归
if (!Objects.isNull(ttUser.getParentId())) {
distributeCommission(ttUser.getParentId(),
ttUser.getCommissionRate(),
amount);
}
}
}
File diff suppressed because it is too large Load Diff