Merge branch 'sxsy80' into czleilei240
This commit is contained in:
@@ -148,6 +148,8 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
.antMatchers("/api/admin/store/product/copy/**").permitAll()
|
||||
.antMatchers("/api/admin/merchandise/select").permitAll()
|
||||
.antMatchers("/api/admin/merchandise/update").permitAll()
|
||||
// 老板驾驶舱独立 H5 页面接口,本机演示和报表归档使用
|
||||
.antMatchers("/api/admin/dashboard/**").permitAll()
|
||||
// 积分模块外部免认证只读接口(供 /integral-external/* 页面调用)
|
||||
.antMatchers("/api/external/integral/**").permitAll()
|
||||
// 除上面外的所有请求全部需要鉴权认证
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.zbkj.admin.controller;
|
||||
|
||||
import com.zbkj.common.response.dashboard.BossDashboardResponse;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import com.zbkj.service.service.BossDashboardService;
|
||||
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.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 老板经营驾驶舱
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("api/admin/dashboard")
|
||||
@Api(tags = "老板经营驾驶舱")
|
||||
public class BossDashboardController {
|
||||
|
||||
@Autowired
|
||||
private BossDashboardService bossDashboardService;
|
||||
|
||||
@ApiOperation(value = "老板驾驶舱概览")
|
||||
@RequestMapping(value = "/overview", method = RequestMethod.GET)
|
||||
public CommonResult<BossDashboardResponse> overview(@RequestParam(value = "date", required = false) String date) {
|
||||
return CommonResult.success(bossDashboardService.overview(date));
|
||||
}
|
||||
|
||||
@ApiOperation(value = "生成经营日报归档 HTML")
|
||||
@RequestMapping(value = "/daily-report/archive", method = RequestMethod.GET)
|
||||
public ResponseEntity<byte[]> dailyReportArchive(@RequestParam(value = "date", required = false) String date) {
|
||||
BossDashboardResponse overview = bossDashboardService.overview(date);
|
||||
String html = bossDashboardService.dailyReportArchiveHtml(date);
|
||||
String filename = "dashboard-daily-report-" + overview.getBusinessDate() + ".html";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(new MediaType("text", "html", StandardCharsets.UTF_8));
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"");
|
||||
return ResponseEntity.ok().headers(headers).body(html.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.zbkj.common.response.dashboard;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 老板驾驶舱响应对象
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(value = "BossDashboardResponse", description = "老板驾驶舱响应对象")
|
||||
public class BossDashboardResponse {
|
||||
|
||||
@ApiModelProperty(value = "业务日期")
|
||||
private String businessDate;
|
||||
|
||||
@ApiModelProperty(value = "生成时间")
|
||||
private String generatedAt;
|
||||
|
||||
@ApiModelProperty(value = "经营摘要")
|
||||
private String summary;
|
||||
|
||||
@ApiModelProperty(value = "核心指标")
|
||||
private List<KpiMetric> kpis = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "资金池指标")
|
||||
private List<KpiMetric> fundPool = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "今日节点快报")
|
||||
private List<TodaySnapshot> snapshots = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "近 7 天趋势")
|
||||
private List<TrendPoint> trends = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "高价值用户排行")
|
||||
private List<RankItem> userRanks = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "团队贡献排行")
|
||||
private List<RankItem> teamRanks = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "高货值未成交商品排行")
|
||||
private List<RankItem> productRanks = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "风险预警")
|
||||
private List<RiskAlert> risks = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class KpiMetric {
|
||||
private String key;
|
||||
private String title;
|
||||
private Object value;
|
||||
private String unit;
|
||||
private String trendLabel;
|
||||
private BigDecimal trendValue;
|
||||
private String status = "normal";
|
||||
private Boolean featured = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TodaySnapshot {
|
||||
private String slot;
|
||||
private String title;
|
||||
private String status;
|
||||
private String generatedAt;
|
||||
private String message;
|
||||
private Integer purchaseUsers = 0;
|
||||
private Integer orderCount = 0;
|
||||
private BigDecimal dealAmount = BigDecimal.ZERO;
|
||||
private BigDecimal paidAmount = BigDecimal.ZERO;
|
||||
private Integer newMerchandiseCount = 0;
|
||||
private BigDecimal selfBonusChange = BigDecimal.ZERO;
|
||||
private BigDecimal shareBonusChange = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TrendPoint {
|
||||
private String date;
|
||||
private BigDecimal amount = BigDecimal.ZERO;
|
||||
private Integer orders = 0;
|
||||
private Integer newUsers = 0;
|
||||
private BigDecimal bonus = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RankItem {
|
||||
private String id;
|
||||
private String name;
|
||||
private BigDecimal value = BigDecimal.ZERO;
|
||||
private String description;
|
||||
private String badge;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RiskAlert {
|
||||
private String id;
|
||||
private String level;
|
||||
private String type;
|
||||
private String title;
|
||||
private String description;
|
||||
private String discoveredAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.zbkj.service.service;
|
||||
|
||||
import com.zbkj.common.response.dashboard.BossDashboardResponse;
|
||||
|
||||
/**
|
||||
* 老板经营驾驶舱服务
|
||||
*/
|
||||
public interface BossDashboardService {
|
||||
|
||||
/**
|
||||
* 获取老板经营驾驶舱数据
|
||||
*
|
||||
* @param date 业务日期,格式 yyyy-MM-dd,为空时默认上一个工作日
|
||||
* @return BossDashboardResponse
|
||||
*/
|
||||
BossDashboardResponse overview(String date);
|
||||
|
||||
/**
|
||||
* 生成经营日报归档 HTML
|
||||
*
|
||||
* @param date 业务日期,格式 yyyy-MM-dd,为空时默认上一个工作日
|
||||
* @return standalone HTML
|
||||
*/
|
||||
String dailyReportArchiveHtml(String date);
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import cn.hutool.core.date.DateTime;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.zbkj.common.model.consignment.WaMerchandise;
|
||||
import com.zbkj.common.model.consignment.WaOrder;
|
||||
import com.zbkj.common.model.consignment.WaSelfbonusLog;
|
||||
import com.zbkj.common.model.consignment.WaSharebonusLog;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.model.consignment.WaWithdraw;
|
||||
import com.zbkj.common.response.dashboard.BossDashboardResponse;
|
||||
import com.zbkj.service.dao.consignment.WaMerchandiseDao;
|
||||
import com.zbkj.service.dao.consignment.WaOrderDao;
|
||||
import com.zbkj.service.dao.consignment.WaSelfbonusLogDao;
|
||||
import com.zbkj.service.dao.consignment.WaSharebonusLogDao;
|
||||
import com.zbkj.service.dao.consignment.WaUsersDao;
|
||||
import com.zbkj.service.dao.consignment.WaWithdrawDao;
|
||||
import com.zbkj.service.service.BossDashboardService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 老板经营驾驶舱服务实现
|
||||
*/
|
||||
@Service
|
||||
public class BossDashboardServiceImpl implements BossDashboardService {
|
||||
|
||||
@Resource
|
||||
private WaOrderDao waOrderDao;
|
||||
|
||||
@Resource
|
||||
private WaMerchandiseDao waMerchandiseDao;
|
||||
|
||||
@Resource
|
||||
private WaUsersDao waUsersDao;
|
||||
|
||||
@Resource
|
||||
private WaSelfbonusLogDao waSelfbonusLogDao;
|
||||
|
||||
@Resource
|
||||
private WaSharebonusLogDao waSharebonusLogDao;
|
||||
|
||||
@Resource
|
||||
private WaWithdrawDao waWithdrawDao;
|
||||
|
||||
@Override
|
||||
public BossDashboardResponse overview(String date) {
|
||||
DateTime businessDate = StrUtil.isBlank(date) ? previousWorkday(DateUtil.date()) : DateUtil.parseDate(date);
|
||||
DateTime previousDate = previousWorkday(businessDate);
|
||||
DateRange businessRange = dayRange(businessDate);
|
||||
DateRange previousRange = dayRange(previousDate);
|
||||
|
||||
DailyMetrics metrics = buildDailyMetrics(businessRange);
|
||||
DailyMetrics previousMetrics = buildDailyMetrics(previousRange);
|
||||
|
||||
BossDashboardResponse response = new BossDashboardResponse();
|
||||
response.setBusinessDate(businessDate.toString("yyyy-MM-dd"));
|
||||
response.setGeneratedAt(DateUtil.formatDateTime(new Date()));
|
||||
response.setSummary(buildSummary(metrics));
|
||||
response.getKpis().add(metric("dealAmount", "上个工作日成交额", metrics.dealAmount, "元", "较上一工作日", ratio(metrics.dealAmount, previousMetrics.dealAmount), statusByRatio(metrics.dealAmount, previousMetrics.dealAmount), true));
|
||||
response.getKpis().add(metric("orderCount", "上个工作日订单数", metrics.orderCount, "单", "较上一工作日", ratio(metrics.orderCount, previousMetrics.orderCount), statusByRatio(metrics.orderCount, previousMetrics.orderCount), false));
|
||||
response.getKpis().add(metric("purchaseUsers", "采购用户", metrics.purchaseUsers, "人", "较上一工作日", ratio(metrics.purchaseUsers, previousMetrics.purchaseUsers), statusByRatio(metrics.purchaseUsers, previousMetrics.purchaseUsers), false));
|
||||
response.getKpis().add(metric("newUsers", "新增用户", metrics.newUsers, "人", "较上一工作日", ratio(metrics.newUsers, previousMetrics.newUsers), statusByRatio(metrics.newUsers, previousMetrics.newUsers), false));
|
||||
response.getKpis().add(metric("newMerchandise", "新增寄售商品", metrics.newMerchandiseCount, "件", "较上一工作日", ratio(metrics.newMerchandiseCount, previousMetrics.newMerchandiseCount), statusByRatio(metrics.newMerchandiseCount, previousMetrics.newMerchandiseCount), false));
|
||||
response.getKpis().add(metric("selfBonus", "个人奖金发放", metrics.selfBonus, "元", "较上一工作日", ratio(metrics.selfBonus, previousMetrics.selfBonus), "normal", false));
|
||||
response.getKpis().add(metric("shareBonus", "推广奖金发放", metrics.shareBonus, "元", "较上一工作日", ratio(metrics.shareBonus, previousMetrics.shareBonus), "normal", false));
|
||||
response.getKpis().add(metric("pendingAmount", "待支付/待结算", metrics.pendingAmount, "元", "需关注", null, metrics.pendingAmount.compareTo(BigDecimal.ZERO) > 0 ? "warning" : "normal", false));
|
||||
|
||||
buildFundPool(response);
|
||||
buildSnapshots(response);
|
||||
buildTrends(response, businessDate);
|
||||
buildRanks(response);
|
||||
buildRisks(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String dailyReportArchiveHtml(String date) {
|
||||
BossDashboardResponse data = overview(date);
|
||||
StringBuilder html = new StringBuilder();
|
||||
html.append("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">");
|
||||
html.append("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">");
|
||||
html.append("<title>经营日报归档 - ").append(escape(data.getBusinessDate())).append("</title>");
|
||||
html.append("<style>");
|
||||
html.append(":root{color:#132033;background:#fff6f1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,rgba(255,91,54,.18),transparent 30rem),#fff6f1}.page{max-width:820px;margin:0 auto;padding:28px 18px 40px}.hero{color:#fff;padding:26px;border-radius:0 0 28px 28px;background:linear-gradient(145deg,#ff5b36,#ff8b52),radial-gradient(circle at 90% 10%,rgba(255,176,0,.42),transparent 18rem);box-shadow:0 16px 40px rgba(255,91,54,.14)}.eyebrow{margin:0;color:rgba(255,255,255,.76);font-size:12px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.hero h1{margin:12px 0 8px;font-size:32px;line-height:1.1}.hero p{margin:0;color:rgba(255,255,255,.82);line-height:1.7}.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.meta span{padding:7px 12px;border-radius:999px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.2);font-size:12px;font-weight:700}.section{margin-top:16px;padding:18px;background:#fff;border:1px solid rgba(19,32,51,.08);border-radius:24px;box-shadow:0 10px 28px rgba(22,47,80,.08)}.section h2{margin:0 0 14px;font-size:20px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.card{padding:14px;border-radius:18px;background:#f6f9fb}.card small{display:block;color:#6b7a90}.card strong{display:block;margin-top:6px;font-size:22px}.list{display:grid;gap:10px}.item{padding:12px;border-radius:16px;background:#f6f9fb}.item strong{display:block}.item small,.item p{color:#6b7a90}.risk-red strong{color:#dc2626}.risk-yellow strong{color:#ffb000}.footer{margin-top:18px;color:#6b7a90;font-size:12px;text-align:center}@media(max-width:560px){.grid{grid-template-columns:1fr}.hero h1{font-size:28px}}");
|
||||
html.append("</style></head><body><main class=\"page\">");
|
||||
html.append("<header class=\"hero\"><p class=\"eyebrow\">Daily Report Archive</p><h1>经营日报归档</h1>");
|
||||
html.append("<p>").append(escape(data.getSummary())).append("</p><div class=\"meta\">");
|
||||
html.append("<span>数据日期:").append(escape(data.getBusinessDate())).append("</span>");
|
||||
html.append("<span>生成时间:").append(escape(data.getGeneratedAt())).append("</span>");
|
||||
html.append("<span>归档类型:Standalone HTML</span></div></header>");
|
||||
appendMetricsSection(html, "核心经营指标", data.getKpis());
|
||||
appendTrendSection(html, data);
|
||||
appendMetricsSection(html, "资金池摘要", data.getFundPool());
|
||||
appendRankSection(html, "高价值用户", data.getUserRanks());
|
||||
appendRankSection(html, "团队贡献排行", data.getTeamRanks());
|
||||
appendRankSection(html, "高货值未成交商品", data.getProductRanks());
|
||||
appendRiskSection(html, data);
|
||||
html.append("<p class=\"footer\">本归档由经营驾驶舱实时数据生成,可独立保存和打开。</p>");
|
||||
html.append("</main></body></html>");
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private DailyMetrics buildDailyMetrics(DateRange range) {
|
||||
DailyMetrics metrics = new DailyMetrics();
|
||||
metrics.dealAmount = sumOrderAmount(range.start, range.end, true, null);
|
||||
metrics.orderCount = countOrders(range.start, range.end, null);
|
||||
metrics.purchaseUsers = distinctBuyerCount(range.start, range.end, null);
|
||||
metrics.newUsers = countUsers(range.start, range.end);
|
||||
metrics.newMerchandiseCount = countMerchandise(range.start, range.end);
|
||||
metrics.selfBonus = sumSelfBonus(range.start, range.end);
|
||||
metrics.shareBonus = sumShareBonus(range.start, range.end);
|
||||
metrics.pendingAmount = sumOrderAmount(range.start, range.end, false, null);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private void buildFundPool(BossDashboardResponse response) {
|
||||
BigDecimal money = sumUsersDecimal("money");
|
||||
BigDecimal coupon = sumUsersDecimal("coupon");
|
||||
BigDecimal selfBonus = sumUsersDecimal("self_bonus");
|
||||
BigDecimal shareBonus = sumUsersDecimal("share_bonus");
|
||||
BigDecimal score = sumUsersDecimal("score");
|
||||
BigDecimal pendingWithdraw = sumWithdrawAmount(0);
|
||||
Integer pendingWithdrawCount = countWithdraw(0);
|
||||
|
||||
response.getFundPool().add(metric("balance", "余额总额", money, "元", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("coupon", "优惠券总额", coupon, "元", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("selfBonusPool", "个人奖金总额", selfBonus, "元", null, null, selfBonus.compareTo(BigDecimal.ZERO) > 0 ? "warning" : "normal", false));
|
||||
response.getFundPool().add(metric("shareBonusPool", "推广奖金总额", shareBonus, "元", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("integral", "积分总额", score, "分", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("withdrawPending", "待审核提现", pendingWithdraw, "元", pendingWithdrawCount + " 笔", null, pendingWithdrawCount > 0 ? "danger" : "normal", false));
|
||||
}
|
||||
|
||||
private void buildSnapshots(BossDashboardResponse response) {
|
||||
DateTime today = DateUtil.date();
|
||||
DateRange morningRange = range(today, "00:00:00", "10:15:59");
|
||||
DateRange afternoonRange = range(today, "10:16:00", "14:55:59");
|
||||
|
||||
DailyMetrics morningMetrics = buildDailyMetrics(morningRange);
|
||||
BossDashboardResponse.TodaySnapshot morning = snapshot("1015", "10:15 上午快报", morningRange.end, "上午抢购节点已完成,上一日寄卖商品消化情况请关注成交额、付款和采购用户。", morningMetrics, "success");
|
||||
response.getSnapshots().add(morning);
|
||||
|
||||
String afternoonStatus = new Date().after(afternoonRange.end) ? "success" : "pending";
|
||||
String afternoonMessage = "success".equals(afternoonStatus)
|
||||
? "下午寄卖/转卖节点已完成,请关注用户抢购商品的再次上架与转卖承接。"
|
||||
: "下午寄卖/转卖节点尚未生成,预计 14:55 后可查看用户抢购商品的再次上架情况。";
|
||||
DailyMetrics afternoonMetrics = "success".equals(afternoonStatus) ? buildDailyMetrics(afternoonRange) : new DailyMetrics();
|
||||
response.getSnapshots().add(snapshot("1455", "14:55 下午快报", afternoonRange.end, afternoonMessage, afternoonMetrics, afternoonStatus));
|
||||
}
|
||||
|
||||
private void buildTrends(BossDashboardResponse response, DateTime businessDate) {
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
DateTime date = DateUtil.offsetDay(businessDate, -i);
|
||||
DailyMetrics metrics = buildDailyMetrics(dayRange(date));
|
||||
BossDashboardResponse.TrendPoint point = new BossDashboardResponse.TrendPoint();
|
||||
point.setDate(date.toString("MM-dd"));
|
||||
point.setAmount(metrics.dealAmount);
|
||||
point.setOrders(metrics.orderCount);
|
||||
point.setNewUsers(metrics.newUsers);
|
||||
point.setBonus(metrics.selfBonus.add(metrics.shareBonus));
|
||||
response.getTrends().add(point);
|
||||
}
|
||||
}
|
||||
|
||||
private void buildRanks(BossDashboardResponse response) {
|
||||
QueryWrapper<WaUsers> userWrapper = new QueryWrapper<WaUsers>()
|
||||
.select("id", "nickname", "mobile", "self_bonus", "share_bonus", "coupon", "score")
|
||||
.orderByDesc("IFNULL(self_bonus,0) + IFNULL(share_bonus,0) + IFNULL(coupon,0)")
|
||||
.last("limit 3");
|
||||
List<WaUsers> users = waUsersDao.selectList(userWrapper);
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
WaUsers user = users.get(i);
|
||||
BigDecimal value = defaultDecimal(user.getSelfBonus()).add(defaultDecimal(user.getShareBonus())).add(defaultDecimal(user.getCoupon()));
|
||||
response.getUserRanks().add(rank("u" + user.getId(), displayName(user), value, maskMobile(user.getMobile()), i == 0 ? "高价值" : null));
|
||||
}
|
||||
|
||||
QueryWrapper<WaUsers> teamWrapper = new QueryWrapper<WaUsers>()
|
||||
.select("pid as id", "COUNT(id) as memberCount", "IFNULL(SUM(self_bonus),0) as selfBonus", "IFNULL(SUM(share_bonus),0) as shareBonus")
|
||||
.isNotNull("pid")
|
||||
.gt("pid", 0)
|
||||
.groupBy("pid")
|
||||
.orderByDesc("IFNULL(SUM(self_bonus),0) + IFNULL(SUM(share_bonus),0)")
|
||||
.last("limit 3");
|
||||
List<Map<String, Object>> teams = waUsersDao.selectMaps(teamWrapper);
|
||||
for (int i = 0; i < teams.size(); i++) {
|
||||
Map<String, Object> team = teams.get(i);
|
||||
BigDecimal selfBonus = decimal(team.get("selfBonus"));
|
||||
BigDecimal shareBonus = decimal(team.get("shareBonus"));
|
||||
String leaderId = stringValue(team.get("id"));
|
||||
WaUsers leader = waUsersDao.selectById(leaderId);
|
||||
String leaderName = leader == null ? "团队 " + leaderId : displayName(leader);
|
||||
response.getTeamRanks().add(rank("t" + leaderId, leaderName, selfBonus.add(shareBonus), "成员 " + intValue(team.get("memberCount")) + " 人", i == 0 ? "TOP1" : null));
|
||||
}
|
||||
|
||||
QueryWrapper<WaMerchandise> productWrapper = new QueryWrapper<WaMerchandise>()
|
||||
.select("id", "title", "price", "created_at")
|
||||
.eq("status", 1)
|
||||
.orderByDesc("price")
|
||||
.last("limit 3");
|
||||
List<WaMerchandise> products = waMerchandiseDao.selectList(productWrapper);
|
||||
for (int i = 0; i < products.size(); i++) {
|
||||
WaMerchandise product = products.get(i);
|
||||
response.getProductRanks().add(rank("p" + product.getId(), StrUtil.isBlank(product.getTitle()) ? "未命名商品" : product.getTitle(), defaultDecimal(product.getPrice()), "高货值待成交", i == 0 ? "滞销" : null));
|
||||
}
|
||||
}
|
||||
|
||||
private void buildRisks(BossDashboardResponse response) {
|
||||
BigDecimal pendingWithdraw = sumWithdrawAmount(0);
|
||||
if (pendingWithdraw.compareTo(BigDecimal.ZERO) > 0) {
|
||||
response.getRisks().add(risk("r1", "red", "资金", "待审核提现", "当前待审核提现 " + pendingWithdraw + " 元,建议今日处理。"));
|
||||
}
|
||||
|
||||
Integer pendingOrders = countPendingOrders();
|
||||
if (pendingOrders > 0) {
|
||||
response.getRisks().add(risk("r2", "yellow", "订单", "待支付订单未处理", "当前存在 " + pendingOrders + " 笔待支付订单,请关注付款转化。"));
|
||||
}
|
||||
|
||||
Integer hiddenProducts = countHiddenMerchandise();
|
||||
if (hiddenProducts > 0) {
|
||||
response.getRisks().add(risk("r3", "gray", "商品", "隐藏寄售商品", "当前存在 " + hiddenProducts + " 个隐藏寄售商品,可按需核查。"));
|
||||
}
|
||||
}
|
||||
|
||||
private BossDashboardResponse.KpiMetric metric(String key, String title, Object value, String unit, String trendLabel, BigDecimal trendValue, String status, Boolean featured) {
|
||||
BossDashboardResponse.KpiMetric metric = new BossDashboardResponse.KpiMetric();
|
||||
metric.setKey(key);
|
||||
metric.setTitle(title);
|
||||
metric.setValue(value);
|
||||
metric.setUnit(unit);
|
||||
metric.setTrendLabel(trendLabel);
|
||||
metric.setTrendValue(trendValue);
|
||||
metric.setStatus(status);
|
||||
metric.setFeatured(featured);
|
||||
return metric;
|
||||
}
|
||||
|
||||
private BossDashboardResponse.TodaySnapshot snapshot(String slot, String title, Date generatedAt, String message, DailyMetrics metrics, String status) {
|
||||
BossDashboardResponse.TodaySnapshot snapshot = new BossDashboardResponse.TodaySnapshot();
|
||||
snapshot.setSlot(slot);
|
||||
snapshot.setTitle(title);
|
||||
snapshot.setStatus(status);
|
||||
snapshot.setGeneratedAt(DateUtil.formatDateTime(generatedAt));
|
||||
snapshot.setMessage(message);
|
||||
snapshot.setPurchaseUsers(metrics.purchaseUsers);
|
||||
snapshot.setOrderCount(metrics.orderCount);
|
||||
snapshot.setDealAmount(metrics.dealAmount);
|
||||
snapshot.setPaidAmount(metrics.dealAmount);
|
||||
snapshot.setNewMerchandiseCount(metrics.newMerchandiseCount);
|
||||
snapshot.setSelfBonusChange(metrics.selfBonus);
|
||||
snapshot.setShareBonusChange(metrics.shareBonus);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private BossDashboardResponse.RankItem rank(String id, String name, BigDecimal value, String description, String badge) {
|
||||
BossDashboardResponse.RankItem rank = new BossDashboardResponse.RankItem();
|
||||
rank.setId(id);
|
||||
rank.setName(name);
|
||||
rank.setValue(value);
|
||||
rank.setDescription(description);
|
||||
rank.setBadge(badge);
|
||||
return rank;
|
||||
}
|
||||
|
||||
private BossDashboardResponse.RiskAlert risk(String id, String level, String type, String title, String description) {
|
||||
BossDashboardResponse.RiskAlert risk = new BossDashboardResponse.RiskAlert();
|
||||
risk.setId(id);
|
||||
risk.setLevel(level);
|
||||
risk.setType(type);
|
||||
risk.setTitle(title);
|
||||
risk.setDescription(description);
|
||||
risk.setDiscoveredAt(DateUtil.format(new Date(), "HH:mm"));
|
||||
return risk;
|
||||
}
|
||||
|
||||
private void appendMetricsSection(StringBuilder html, String title, List<BossDashboardResponse.KpiMetric> metrics) {
|
||||
html.append("<section class=\"section\"><h2>").append(escape(title)).append("</h2><div class=\"grid\">");
|
||||
for (BossDashboardResponse.KpiMetric metric : metrics) {
|
||||
html.append("<article class=\"card\"><small>").append(escape(metric.getTitle())).append("</small>");
|
||||
html.append("<strong>").append(formatMetric(metric.getValue(), metric.getUnit())).append("</strong>");
|
||||
if (StrUtil.isNotBlank(metric.getTrendLabel()) || metric.getTrendValue() != null) {
|
||||
html.append("<small>").append(escape(metric.getTrendLabel()));
|
||||
if (metric.getTrendValue() != null) {
|
||||
html.append(" ").append(metric.getTrendValue()).append("%");
|
||||
}
|
||||
html.append("</small>");
|
||||
}
|
||||
html.append("</article>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private void appendTrendSection(StringBuilder html, BossDashboardResponse data) {
|
||||
html.append("<section class=\"section\"><h2>最近 7 天趋势</h2><div class=\"list\">");
|
||||
for (BossDashboardResponse.TrendPoint point : data.getTrends()) {
|
||||
html.append("<div class=\"item\"><strong>").append(escape(point.getDate())).append(":").append(formatMoney(point.getAmount())).append("</strong>");
|
||||
html.append("<small>").append(point.getOrders()).append(" 单 / 新增用户 ").append(point.getNewUsers()).append(" / 奖金 ").append(formatMoney(point.getBonus())).append("</small></div>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private void appendRankSection(StringBuilder html, String title, List<BossDashboardResponse.RankItem> ranks) {
|
||||
html.append("<section class=\"section\"><h2>").append(escape(title)).append("</h2><div class=\"list\">");
|
||||
if (ranks.isEmpty()) {
|
||||
html.append("<div class=\"item\"><strong>暂无数据</strong><small>当前实时数据未生成该排行。</small></div>");
|
||||
}
|
||||
for (int i = 0; i < ranks.size(); i++) {
|
||||
BossDashboardResponse.RankItem rank = ranks.get(i);
|
||||
html.append("<div class=\"item\"><strong>").append(i + 1).append(". ").append(escape(rank.getName())).append(" · ").append(formatMoney(rank.getValue())).append("</strong>");
|
||||
html.append("<small>").append(escape(rank.getDescription()));
|
||||
if (StrUtil.isNotBlank(rank.getBadge())) {
|
||||
html.append(" / ").append(escape(rank.getBadge()));
|
||||
}
|
||||
html.append("</small></div>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private void appendRiskSection(StringBuilder html, BossDashboardResponse data) {
|
||||
html.append("<section class=\"section\"><h2>风险预警</h2><div class=\"list\">");
|
||||
if (data.getRisks().isEmpty()) {
|
||||
html.append("<div class=\"item\"><strong>暂无风险</strong><small>当前实时数据未触发风险预警。</small></div>");
|
||||
}
|
||||
for (BossDashboardResponse.RiskAlert risk : data.getRisks()) {
|
||||
html.append("<div class=\"item risk-").append(escape(risk.getLevel())).append("\"><strong>");
|
||||
html.append(escape(risk.getType())).append(" / ").append(escape(risk.getTitle())).append("</strong>");
|
||||
html.append("<p>").append(escape(risk.getDescription())).append("</p>");
|
||||
html.append("<small>发现时间:").append(escape(risk.getDiscoveredAt())).append("</small></div>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private BigDecimal sumOrderAmount(Date start, Date end, Boolean paidOnly, Boolean isResell) {
|
||||
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().select("IFNULL(SUM(total_money),0) as total").between("buy_time", start, end);
|
||||
wrapper.eq("is_cancel", 0);
|
||||
if (Boolean.TRUE.equals(paidOnly)) {
|
||||
wrapper.ge("status", 1);
|
||||
} else if (Boolean.FALSE.equals(paidOnly)) {
|
||||
wrapper.eq("status", 0);
|
||||
}
|
||||
if (isResell != null) {
|
||||
wrapper.eq("is_resell", isResell ? 1 : 0);
|
||||
}
|
||||
return aggregateDecimal(waOrderDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private Integer countOrders(Date start, Date end, Boolean isResell) {
|
||||
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().between("buy_time", start, end).eq("is_cancel", 0);
|
||||
if (isResell != null) {
|
||||
wrapper.eq("is_resell", isResell ? 1 : 0);
|
||||
}
|
||||
return waOrderDao.selectCount(wrapper);
|
||||
}
|
||||
|
||||
private Integer distinctBuyerCount(Date start, Date end, Boolean isResell) {
|
||||
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().select("COUNT(DISTINCT buyer_id) as total").between("buy_time", start, end).eq("is_cancel", 0);
|
||||
if (isResell != null) {
|
||||
wrapper.eq("is_resell", isResell ? 1 : 0);
|
||||
}
|
||||
return aggregateInt(waOrderDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private Integer countUsers(Date start, Date end) {
|
||||
return waUsersDao.selectCount(new QueryWrapper<WaUsers>().between("join_time", start, end));
|
||||
}
|
||||
|
||||
private Integer countMerchandise(Date start, Date end) {
|
||||
return waMerchandiseDao.selectCount(new QueryWrapper<WaMerchandise>().between("created_at", start, end));
|
||||
}
|
||||
|
||||
private BigDecimal sumSelfBonus(Date start, Date end) {
|
||||
QueryWrapper<WaSelfbonusLog> wrapper = new QueryWrapper<WaSelfbonusLog>().select("IFNULL(SUM(money),0) as total").eq("type", 1).between("created_at", start, end);
|
||||
return aggregateDecimal(waSelfbonusLogDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private BigDecimal sumShareBonus(Date start, Date end) {
|
||||
QueryWrapper<WaSharebonusLog> wrapper = new QueryWrapper<WaSharebonusLog>().select("IFNULL(SUM(money),0) as total").eq("type", 1).between("created_at", start, end);
|
||||
return aggregateDecimal(waSharebonusLogDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private BigDecimal sumUsersDecimal(String column) {
|
||||
QueryWrapper<WaUsers> wrapper = new QueryWrapper<WaUsers>().select("IFNULL(SUM(" + column + "),0) as total");
|
||||
return aggregateDecimal(waUsersDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private BigDecimal sumWithdrawAmount(Integer status) {
|
||||
QueryWrapper<WaWithdraw> wrapper = new QueryWrapper<WaWithdraw>().select("IFNULL(SUM(money),0) as total").eq("status", status);
|
||||
return aggregateDecimal(waWithdrawDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private Integer countWithdraw(Integer status) {
|
||||
return waWithdrawDao.selectCount(new QueryWrapper<WaWithdraw>().eq("status", status));
|
||||
}
|
||||
|
||||
private Integer countPendingOrders() {
|
||||
return waOrderDao.selectCount(new QueryWrapper<WaOrder>().eq("status", 0).eq("is_cancel", 0));
|
||||
}
|
||||
|
||||
private Integer countHiddenMerchandise() {
|
||||
return waMerchandiseDao.selectCount(new QueryWrapper<WaMerchandise>().eq("is_show", 0));
|
||||
}
|
||||
|
||||
private BigDecimal ratio(BigDecimal current, BigDecimal previous) {
|
||||
if (previous == null || previous.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return current != null && current.compareTo(BigDecimal.ZERO) > 0 ? BigDecimal.valueOf(100) : BigDecimal.ZERO;
|
||||
}
|
||||
return current.subtract(previous).multiply(BigDecimal.valueOf(100)).divide(previous, 1, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BigDecimal ratio(Integer current, Integer previous) {
|
||||
return ratio(BigDecimal.valueOf(current == null ? 0 : current), BigDecimal.valueOf(previous == null ? 0 : previous));
|
||||
}
|
||||
|
||||
private String statusByRatio(BigDecimal current, BigDecimal previous) {
|
||||
BigDecimal value = ratio(current, previous);
|
||||
return value.compareTo(BigDecimal.ZERO) >= 0 ? "success" : "warning";
|
||||
}
|
||||
|
||||
private String statusByRatio(Integer current, Integer previous) {
|
||||
return statusByRatio(BigDecimal.valueOf(current == null ? 0 : current), BigDecimal.valueOf(previous == null ? 0 : previous));
|
||||
}
|
||||
|
||||
private String buildSummary(DailyMetrics metrics) {
|
||||
if (metrics.dealAmount.compareTo(BigDecimal.ZERO) == 0 && metrics.orderCount == 0) {
|
||||
return "当前日期暂无成交数据,请关注抢购与寄卖节点是否正常生成。";
|
||||
}
|
||||
return "上个工作日成交 " + metrics.dealAmount + " 元,订单 " + metrics.orderCount + " 单,采购用户 " + metrics.purchaseUsers + " 人;请重点关注待支付、提现和寄售供给。";
|
||||
}
|
||||
|
||||
private DateRange dayRange(DateTime date) {
|
||||
return new DateRange(DateUtil.beginOfDay(date), DateUtil.endOfDay(date));
|
||||
}
|
||||
|
||||
private DateTime previousWorkday(DateTime referenceDate) {
|
||||
DateTime date = DateUtil.offsetDay(referenceDate, -1);
|
||||
while (isWeekend(date)) {
|
||||
date = DateUtil.offsetDay(date, -1);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private boolean isWeekend(DateTime date) {
|
||||
int dayOfWeek = DateUtil.dayOfWeek(date);
|
||||
return dayOfWeek == Calendar.SATURDAY || dayOfWeek == Calendar.SUNDAY;
|
||||
}
|
||||
|
||||
private DateRange range(DateTime date, String startTime, String endTime) {
|
||||
String day = date.toString("yyyy-MM-dd");
|
||||
return new DateRange(DateUtil.parse(day + " " + startTime), DateUtil.parse(day + " " + endTime));
|
||||
}
|
||||
|
||||
private BigDecimal aggregateDecimal(List<Map<String, Object>> maps, String key) {
|
||||
if (maps == null || maps.isEmpty()) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return decimal(maps.get(0).get(key));
|
||||
}
|
||||
|
||||
private Integer aggregateInt(List<Map<String, Object>> maps, String key) {
|
||||
if (maps == null || maps.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return intValue(maps.get(0).get(key));
|
||||
}
|
||||
|
||||
private BigDecimal decimal(Object value) {
|
||||
if (value == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
if (value instanceof BigDecimal) {
|
||||
return (BigDecimal) value;
|
||||
}
|
||||
return new BigDecimal(String.valueOf(value));
|
||||
}
|
||||
|
||||
private BigDecimal defaultDecimal(BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
private Integer intValue(Object value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String displayName(WaUsers user) {
|
||||
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||
return user.getNickname();
|
||||
}
|
||||
if (StrUtil.isNotBlank(user.getUsername())) {
|
||||
return user.getUsername();
|
||||
}
|
||||
return "用户 " + user.getId();
|
||||
}
|
||||
|
||||
private String maskMobile(String mobile) {
|
||||
if (StrUtil.isBlank(mobile) || mobile.length() < 7) {
|
||||
return "手机号未完善";
|
||||
}
|
||||
return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4);
|
||||
}
|
||||
|
||||
private String formatMetric(Object value, String unit) {
|
||||
if ("元".equals(unit)) {
|
||||
return formatMoney(decimal(value));
|
||||
}
|
||||
if (value == null) {
|
||||
return "--";
|
||||
}
|
||||
return escape(String.valueOf(value)) + (unit == null ? "" : escape(unit));
|
||||
}
|
||||
|
||||
private String formatMoney(BigDecimal value) {
|
||||
return "¥" + defaultDecimal(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
private String escape(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static class DailyMetrics {
|
||||
private BigDecimal dealAmount = BigDecimal.ZERO;
|
||||
private Integer orderCount = 0;
|
||||
private Integer purchaseUsers = 0;
|
||||
private Integer newUsers = 0;
|
||||
private Integer newMerchandiseCount = 0;
|
||||
private BigDecimal selfBonus = BigDecimal.ZERO;
|
||||
private BigDecimal shareBonus = BigDecimal.ZERO;
|
||||
private BigDecimal pendingAmount = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
private static class DateRange {
|
||||
private Date start;
|
||||
private Date end;
|
||||
|
||||
private DateRange(Date start, Date end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,265 +1,22 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.zbkj.common.model.consignment.WaSelfbonusLog;
|
||||
import com.zbkj.common.model.user.User;
|
||||
import com.zbkj.common.model.user.UserIntegralRecord;
|
||||
import com.zbkj.common.utils.CrmebUtil;
|
||||
import com.zbkj.service.dao.UserDao;
|
||||
import com.zbkj.service.dao.UserIntegralRecordDao;
|
||||
import com.zbkj.service.dao.consignment.WaSelfbonusLogDao;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaSelfbonusSyncService;
|
||||
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.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 用户积分并发安全服务实现类
|
||||
* 专门处理高并发场景下的积分更新,避免数据库锁等待超时
|
||||
* 保留并发安全服务 bean 名称兼容历史调用。
|
||||
* 实际逻辑统一委托到 waSelfbonusSyncService,避免双实现规则漂移。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service("userIntegralConcurrencyService")
|
||||
public class UserIntegralConcurrencyServiceImpl {
|
||||
|
||||
@Autowired
|
||||
private WaSelfbonusLogDao waSelfbonusLogDao;
|
||||
private WaSelfbonusSyncService waSelfbonusSyncService;
|
||||
|
||||
@Autowired
|
||||
private UserIntegralRecordDao userIntegralRecordDao;
|
||||
|
||||
@Autowired
|
||||
private UserDao userDao;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
// 使用ConcurrentHashMap来缓存正在处理的用户ID,防止重复处理
|
||||
private final ConcurrentHashMap<Integer, Object> processingUsers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 同步个人奖金变动到用户积分 - 并发安全版本
|
||||
* 根据个人奖金变动记录,为对应的用户增加积分(奖金金额的50%)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> syncSelfbonusToIntegral() {
|
||||
log.info("开始同步个人奖金变动到用户积分(并发安全版)");
|
||||
|
||||
int successCount = 0;
|
||||
int skipCount = 0;
|
||||
int failCount = 0;
|
||||
|
||||
try {
|
||||
// 查询最新的个人奖金变动记录
|
||||
LambdaQueryWrapper<WaSelfbonusLog> bonusLogWrapper = new LambdaQueryWrapper<>();
|
||||
bonusLogWrapper.orderByDesc(WaSelfbonusLog::getCreatedAt); // 按创建时间倒序
|
||||
bonusLogWrapper.last("LIMIT 500"); // 限制查询500条,避免一次性处理过多
|
||||
List<WaSelfbonusLog> bonusLogList = waSelfbonusLogDao.selectList(bonusLogWrapper);
|
||||
|
||||
for (WaSelfbonusLog bonusLog : bonusLogList) {
|
||||
try {
|
||||
// 检查该奖金记录是否已经处理过
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, bonusLog.getId());
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
|
||||
if (existCount != null && existCount > 0) {
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取用户ID
|
||||
Integer ebUserId = bonusLog.getUserId();
|
||||
|
||||
// 使用同步块确保同一用户不会被重复处理
|
||||
Object lock = processingUsers.computeIfAbsent(ebUserId, k -> new Object());
|
||||
synchronized (lock) {
|
||||
try {
|
||||
// 再次检查积分记录是否已存在(双重检查)
|
||||
existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
if (existCount != null && existCount > 0) {
|
||||
log.debug("奖金记录已在其他线程处理,跳过: bonusLogId={}", bonusLog.getId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证奖金类型和金额
|
||||
if (!isValidBonusLog(bonusLog)) {
|
||||
log.debug("奖金记录不符合处理条件,跳过: bonusLogId={}, type={}, amount={}",
|
||||
bonusLog.getId(), bonusLog.getType(), bonusLog.getMoney());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分值
|
||||
BigDecimal integralValue = calculateIntegralValue(bonusLog.getMoney());
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0或负数,跳过: bonusLogId={}, integralValue={}",
|
||||
bonusLog.getId(), integralValue);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用CAS方式更新积分,避免锁竞争
|
||||
Boolean updateResult = updateIntegralWithRetry(user.getUid(), integralValue, "add", 3);
|
||||
|
||||
if (!updateResult) {
|
||||
log.error("更新用户积分失败(重试后): userId={}, integralValue={}", user.getUid(), integralValue);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 插入积分记录
|
||||
UserIntegralRecord integralRecord = createUserIntegralRecord(user.getUid(), bonusLog, integralValue);
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
log.error("插入积分记录失败: userId={}, bonusLogId={}", user.getUid(), bonusLog.getId());
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
successCount++;
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusLog.getMoney(), integralValue);
|
||||
|
||||
} finally {
|
||||
// 清理锁对象
|
||||
processingUsers.remove(ebUserId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
log.error("处理奖金记录失败: bonusLogId={}, error={}", bonusLog.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", bonusLogList.size());
|
||||
result.put("successCount", successCount);
|
||||
result.put("skipCount", skipCount);
|
||||
result.put("failCount", failCount);
|
||||
|
||||
log.info("同步个人奖金变动到用户积分完成(并发安全版): 总数={}, 成功={}, 跳过={}, 失败={}",
|
||||
bonusLogList.size(), successCount, skipCount, failCount);
|
||||
|
||||
result.put("message", "同步完成");
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("同步个人奖金变动到用户积分异常", e);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("message", "同步失败: " + e.getMessage());
|
||||
result.put("error", e.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证奖金记录是否符合处理条件
|
||||
*/
|
||||
private boolean isValidBonusLog(WaSelfbonusLog bonusLog) {
|
||||
// 只处理收入类型(type=1)
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证奖金金额有效
|
||||
if (bonusLog.getMoney() == null || bonusLog.getMoney().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算积分值
|
||||
*/
|
||||
private BigDecimal calculateIntegralValue(BigDecimal bonusAmount) {
|
||||
// 计算积分:奖金金额 * 50%,向下取整
|
||||
BigDecimal integralDecimal = bonusAmount.multiply(new BigDecimal("0.5"));
|
||||
return integralDecimal.setScale(3, RoundingMode.DOWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建积分记录对象
|
||||
*/
|
||||
private UserIntegralRecord createUserIntegralRecord(Integer userId, WaSelfbonusLog bonusLog, BigDecimal integralValue) {
|
||||
User user = userDao.selectById(userId); // 重新查询用户获取最新积分
|
||||
Integer newIntegral = user != null && user.getIntegral() != null ? user.getIntegral().intValue() : 0;
|
||||
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(userId);
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId())); // 关联奖金记录ID
|
||||
integralRecord.setLinkType("selfbonus"); // 关联类型:个人奖金
|
||||
integralRecord.setType(1); // 类型:1-增加
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(newIntegral); // 实际上应该是更新后的积分,这里可能需要调整
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%d",
|
||||
bonusLog.getMoney(), integralValue.intValue()));
|
||||
integralRecord.setStatus(3); // 状态:3-完成
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId()); // 关联个人奖金记录ID
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
|
||||
return integralRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试机制的积分更新
|
||||
*/
|
||||
private Boolean updateIntegralWithRetry(Integer uid, BigDecimal integral, String type, int maxRetries) {
|
||||
int attempts = 0;
|
||||
Exception lastException = null;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
// 直接更新积分,不再依赖乐观锁
|
||||
Boolean result = userService.operationIntegral(uid, integral, BigDecimal.ZERO, type);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("积分更新失败,准备重试 (attempt {}/{})", attempts, maxRetries);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
log.warn("积分更新异常,准备重试 (attempt {}/{}), error: {}", attempts, maxRetries, e.getMessage());
|
||||
|
||||
// 如果是数据库锁等待超时,等待一段时间再重试
|
||||
if (e.getMessage() != null && e.getMessage().contains("Lock wait timeout")) {
|
||||
try {
|
||||
Thread.sleep(100 * attempts); // 指数退避
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.error("积分更新达到最大重试次数仍然失败,最后一次异常: ", lastException);
|
||||
return false;
|
||||
return waSelfbonusSyncService.syncSelfbonusToIntegral();
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaSelfbonusSyncService;
|
||||
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.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -44,12 +44,14 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
/**
|
||||
* 同步个人奖金变动到用户积分
|
||||
* 根据个人奖金变动记录,为对应的用户增加积分(奖金金额的50%)
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> syncSelfbonusToIntegral() {
|
||||
log.info("开始同步个人奖金变动到用户积分");
|
||||
|
||||
@@ -66,101 +68,16 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
|
||||
for (WaSelfbonusLog bonusLog : bonusLogList) {
|
||||
try {
|
||||
// 检查该奖金记录是否已经处理过(通过 waSelfbonusLogid 字段查询积分记录)
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, bonusLog.getId());
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
|
||||
if (existCount != null && existCount > 0) {
|
||||
// 已处理过,跳过
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
ProcessStatus processStatus = transactionTemplate.execute(status -> processBonusLogAtomically(bonusLog));
|
||||
if (processStatus == ProcessStatus.SKIP) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 根据 wa_users 的 user_id 查找对应的 eb_user 表的 uid
|
||||
// 注意:wa_users.id 对应 eb_user.uid(在同步时已建立关联)
|
||||
Integer ebUserId = bonusLog.getUserId(); // wa_users.id 就是 eb_user.uid
|
||||
|
||||
// 查询 eb_user 表中的用户
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
skipCount++;
|
||||
if (processStatus == ProcessStatus.SUCCESS) {
|
||||
successCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分值:个人奖金变更金额的50%(只处理收入类型的奖金变动)
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
// 只处理收入类型(type=1),支出类型不处理
|
||||
log.debug("跳过非收入类型的奖金变动: bonusLogId={}, type={}", bonusLog.getId(), bonusLog.getType());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 奖金金额(收入为正数)
|
||||
BigDecimal bonusAmount = bonusLog.getMoney();
|
||||
if (bonusAmount == null || bonusAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("奖金金额无效,跳过: bonusLogId={}, money={}", bonusLog.getId(), bonusAmount);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分:奖金金额 * 50%,向下取整
|
||||
BigDecimal integralDecimal = bonusAmount.multiply(new BigDecimal("0.5"));
|
||||
|
||||
BigDecimal integralValue = integralDecimal.setScale(3, RoundingMode.DOWN);
|
||||
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0,跳过: bonusLogId={}, integralValue={}", bonusLog.getId(), integralValue);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新用户积分 - 不再需要当前积分值作为乐观锁条件
|
||||
Boolean updateResult = userService.operationIntegral(
|
||||
user.getUid(),
|
||||
integralValue,
|
||||
BigDecimal.valueOf(0), // 不再使用当前积分作为乐观锁条件
|
||||
"add"
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
log.error("更新用户积分失败: userId={}, integralValue={}", user.getUid(), integralValue);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 重新查询用户获取最新积分
|
||||
user = userDao.selectById(user.getUid());
|
||||
Integer newIntegral = user.getIntegral() != null ? user.getIntegral().intValue() : 0;
|
||||
|
||||
// 新增积分记录
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(user.getUid());
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId())); // 关联奖金记录ID
|
||||
integralRecord.setLinkType("selfbonus"); // 关联类型:个人奖金
|
||||
integralRecord.setType(1); // 类型:1-增加
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(newIntegral);
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%d",
|
||||
bonusAmount, integralValue.intValue()));
|
||||
integralRecord.setStatus(3); // 状态:3-完成
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId()); // 关联个人奖金记录ID
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
log.error("插入积分记录失败: userId={}, bonusLogId={}", user.getUid(), bonusLog.getId());
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
successCount++;
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusAmount, integralValue);
|
||||
failCount++;
|
||||
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
@@ -188,47 +105,104 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 带重试机制的用户积分更新
|
||||
* 原子处理单条奖金日志:
|
||||
* 1) 先插入积分流水(受唯一索引保护)
|
||||
* 2) 插入成功后再更新用户总积分
|
||||
* 3) 回填本条流水的 balance
|
||||
*/
|
||||
private Boolean updateUserIntegralWithRetry(Integer uid, BigDecimal integralValue, int maxRetries) {
|
||||
int attempts = 0;
|
||||
Exception lastException = null;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
Boolean result = userService.operationIntegral(
|
||||
uid,
|
||||
integralValue,
|
||||
BigDecimal.ZERO, // 不再使用当前积分作为乐观锁条件
|
||||
"add"
|
||||
);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("积分更新失败,准备重试 (attempt {}/{})", attempts, maxRetries);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
log.warn("积分更新异常,准备重试 (attempt {}/{}), error: {}", attempts, maxRetries, e.getMessage());
|
||||
|
||||
// 如果是数据库锁等待超时,等待一段时间再重试
|
||||
if (e.getMessage() != null && e.getMessage().contains("Lock wait timeout")) {
|
||||
try {
|
||||
Thread.sleep(100 * attempts); // 指数退避
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
private ProcessStatus processBonusLogAtomically(WaSelfbonusLog bonusLog) {
|
||||
if (isAlreadyProcessed(bonusLog.getId())) {
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
log.error("积分更新达到最大重试次数仍然失败,最后一次异常: ", lastException);
|
||||
return false;
|
||||
|
||||
Integer ebUserId = bonusLog.getUserId();
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
log.debug("跳过非收入类型的奖金变动: bonusLogId={}, type={}", bonusLog.getId(), bonusLog.getType());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
BigDecimal bonusAmount = bonusLog.getMoney();
|
||||
if (bonusAmount == null || bonusAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("奖金金额无效,跳过: bonusLogId={}, money={}", bonusLog.getId(), bonusAmount);
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
BigDecimal integralValue = bonusAmount.multiply(new BigDecimal("0.5")).setScale(3, RoundingMode.DOWN);
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0,跳过: bonusLogId={}, integralValue={}", bonusLog.getId(), integralValue);
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
UserIntegralRecord integralRecord = buildIntegralRecord(user.getUid(), bonusLog, bonusAmount, integralValue);
|
||||
try {
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
throw new IllegalStateException("插入积分记录失败");
|
||||
}
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
// 数据库唯一索引兜底,保证多入口并发只处理一次
|
||||
log.info("奖金记录并发重复处理,已跳过: bonusLogId={}, userId={}", bonusLog.getId(), user.getUid());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
Boolean updateResult = userService.operationIntegral(
|
||||
user.getUid(),
|
||||
integralValue,
|
||||
BigDecimal.ZERO,
|
||||
"add"
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
throw new IllegalStateException(String.format("更新用户积分失败: userId=%s, integralValue=%s", user.getUid(), integralValue));
|
||||
}
|
||||
|
||||
User latestUser = userDao.selectById(user.getUid());
|
||||
Integer latestIntegral = latestUser != null && latestUser.getIntegral() != null ? latestUser.getIntegral().intValue() : 0;
|
||||
integralRecord.setBalance(latestIntegral);
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
userIntegralRecordDao.updateById(integralRecord);
|
||||
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusAmount, integralValue);
|
||||
return ProcessStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private boolean isAlreadyProcessed(Integer waSelfbonusLogId) {
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, waSelfbonusLogId);
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
return existCount != null && existCount > 0;
|
||||
}
|
||||
|
||||
private UserIntegralRecord buildIntegralRecord(Integer uid, WaSelfbonusLog bonusLog, BigDecimal bonusAmount, BigDecimal integralValue) {
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(uid);
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId()));
|
||||
integralRecord.setLinkType("selfbonus");
|
||||
integralRecord.setType(1);
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(0);
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%.3f",
|
||||
bonusAmount, integralValue));
|
||||
integralRecord.setStatus(3);
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId());
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
return integralRecord;
|
||||
}
|
||||
|
||||
private enum ProcessStatus {
|
||||
SUCCESS,
|
||||
SKIP
|
||||
}
|
||||
}
|
||||
|
||||
23
backend/sql/add_unique_index_uk_integral_selfbonus_log.sql
Normal file
23
backend/sql/add_unique_index_uk_integral_selfbonus_log.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Add strong idempotency guard for selfbonus -> integral conversion.
|
||||
-- This index guarantees one wa_selfbonus_log can map to at most one integral record.
|
||||
-- Prerequisite: clear duplicate wa_selfbonus_logid rows first.
|
||||
|
||||
-- Pre-checks
|
||||
SELECT wa_selfbonus_logid, COUNT(*) AS cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY wa_selfbonus_logid
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT 20;
|
||||
|
||||
SELECT COUNT(*) AS zero_cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE wa_selfbonus_logid = 0;
|
||||
|
||||
-- Apply unique index
|
||||
ALTER TABLE eb_user_integral_record
|
||||
ADD UNIQUE KEY uk_integral_selfbonus_log (wa_selfbonus_logid);
|
||||
|
||||
-- Verify index exists
|
||||
SHOW INDEX FROM eb_user_integral_record
|
||||
WHERE Key_name = 'uk_integral_selfbonus_log';
|
||||
130
backend/sql/fix_duplicate_selfbonus_integral_records.sql
Normal file
130
backend/sql/fix_duplicate_selfbonus_integral_records.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
-- Purpose:
|
||||
-- 1) Find duplicate selfbonus integral rows generated from same wa_selfbonus_log
|
||||
-- 2) Backup affected data
|
||||
-- 3) Keep min(id), delete duplicate rows
|
||||
-- 4) Resync eb_user.integral from remaining ledger rows
|
||||
-- 5) Rebuild integer balance snapshot for affected users
|
||||
--
|
||||
-- Notes:
|
||||
-- - Designed for MySQL 5.7
|
||||
-- - Run during low traffic window
|
||||
-- - Review backup table names before execution
|
||||
|
||||
-- 0) Preview duplicate groups
|
||||
SELECT uid,
|
||||
wa_selfbonus_logid,
|
||||
link_id,
|
||||
COUNT(*) AS cnt,
|
||||
SUM(integral) AS total_integral,
|
||||
MIN(id) AS keep_id,
|
||||
GROUP_CONCAT(id ORDER BY id) AS record_ids
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 1) Backup duplicate rows and affected users
|
||||
DROP TABLE IF EXISTS backup_euir_selfbonus_dups_20260511_0959;
|
||||
CREATE TABLE backup_euir_selfbonus_dups_20260511_0959 AS
|
||||
SELECT e.*
|
||||
FROM eb_user_integral_record e
|
||||
JOIN (
|
||||
SELECT uid, wa_selfbonus_logid, link_id, MIN(id) AS keep_id, COUNT(*) AS cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1
|
||||
) d
|
||||
ON d.uid = e.uid
|
||||
AND d.wa_selfbonus_logid = e.wa_selfbonus_logid
|
||||
AND d.link_id = e.link_id;
|
||||
|
||||
DROP TABLE IF EXISTS backup_eb_user_integral_before_fix_20260511_0959;
|
||||
CREATE TABLE backup_eb_user_integral_before_fix_20260511_0959 AS
|
||||
SELECT u.*
|
||||
FROM eb_user u
|
||||
WHERE u.uid IN (
|
||||
SELECT DISTINCT uid FROM backup_euir_selfbonus_dups_20260511_0959
|
||||
);
|
||||
|
||||
-- 2) Deduplicate + resync in one transaction
|
||||
START TRANSACTION;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_dup_groups;
|
||||
CREATE TEMPORARY TABLE tmp_dup_groups AS
|
||||
SELECT uid, wa_selfbonus_logid, link_id, MIN(id) AS keep_id
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_affected_uids;
|
||||
CREATE TEMPORARY TABLE tmp_affected_uids AS
|
||||
SELECT DISTINCT uid FROM tmp_dup_groups;
|
||||
|
||||
DELETE e
|
||||
FROM eb_user_integral_record e
|
||||
JOIN tmp_dup_groups d
|
||||
ON d.uid = e.uid
|
||||
AND d.wa_selfbonus_logid = e.wa_selfbonus_logid
|
||||
AND d.link_id = e.link_id
|
||||
WHERE e.id <> d.keep_id;
|
||||
|
||||
UPDATE eb_user u
|
||||
JOIN (
|
||||
SELECT r.uid, COALESCE(SUM(r.integral), 0) AS sum_integral
|
||||
FROM eb_user_integral_record r
|
||||
JOIN tmp_affected_uids t ON t.uid = r.uid
|
||||
GROUP BY r.uid
|
||||
) s ON s.uid = u.uid
|
||||
SET u.integral = s.sum_integral;
|
||||
|
||||
SET @run_uid := 0;
|
||||
SET @run_bal := 0;
|
||||
UPDATE eb_user_integral_record e
|
||||
JOIN (
|
||||
SELECT t.id, FLOOR(t.running) AS new_balance
|
||||
FROM (
|
||||
SELECT s.id,
|
||||
s.uid,
|
||||
(@run_bal := IF(@run_uid = s.uid, @run_bal + s.integral, s.integral)) AS running,
|
||||
(@run_uid := s.uid) AS uid_guard
|
||||
FROM (
|
||||
SELECT id, uid, integral
|
||||
FROM eb_user_integral_record
|
||||
WHERE uid IN (SELECT uid FROM tmp_affected_uids)
|
||||
ORDER BY uid, id
|
||||
) s
|
||||
) t
|
||||
) x ON x.id = e.id
|
||||
SET e.balance = x.new_balance;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 3) Post-checks
|
||||
SELECT COUNT(*) AS remaining_dup_groups
|
||||
FROM (
|
||||
SELECT 1
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1
|
||||
) a;
|
||||
|
||||
SELECT u.uid, u.integral, s.sum_integral
|
||||
FROM eb_user u
|
||||
JOIN (
|
||||
SELECT uid, SUM(integral) AS sum_integral
|
||||
FROM eb_user_integral_record
|
||||
GROUP BY uid
|
||||
) s ON s.uid = u.uid
|
||||
WHERE u.uid IN (SELECT DISTINCT uid FROM backup_euir_selfbonus_dups_20260511_0959)
|
||||
ORDER BY u.uid;
|
||||
Reference in New Issue
Block a user