ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/TAppUserSiteFavoriteFallbackFactory.java
New file @@ -0,0 +1,32 @@ package com.ruoyi.account.api.factory; import com.ruoyi.account.api.feignClient.TAppUserSiteFavoriteClient; import com.ruoyi.account.api.vo.SitePriceDropNotifyVO; import com.ruoyi.common.core.domain.R; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cloud.openfeign.FallbackFactory; import org.springframework.stereotype.Component; import java.util.List; /** * 收藏站点服务降级处理 */ @Component public class TAppUserSiteFavoriteFallbackFactory implements FallbackFactory<TAppUserSiteFavoriteClient> { private static final Logger log = LoggerFactory.getLogger(TAppUserSiteFavoriteFallbackFactory.class); @Override public TAppUserSiteFavoriteClient create(Throwable throwable) { log.error("收藏站点服务调用失败:{}", throwable.getMessage()); return new TAppUserSiteFavoriteClient() { @Override public R<Void> notifyPriceDrop(List<SitePriceDropNotifyVO> drops) { return R.fail("收藏站点降价推送失败:" + throwable.getMessage()); } }; } } ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/TAppUserSiteFavoriteClient.java
New file @@ -0,0 +1,27 @@ package com.ruoyi.account.api.feignClient; import com.ruoyi.account.api.factory.TAppUserSiteFavoriteFallbackFactory; import com.ruoyi.account.api.vo.SitePriceDropNotifyVO; import com.ruoyi.common.core.constant.ServiceNameConstants; import com.ruoyi.common.core.domain.R; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import java.util.List; /** * 用户收藏站点服务(内部接口) */ @FeignClient(contextId = "TAppUserSiteFavoriteClient", value = ServiceNameConstants.ACCOUNT_SERVICE, fallbackFactory = TAppUserSiteFavoriteFallbackFactory.class) public interface TAppUserSiteFavoriteClient { /** * 计费策略审批通过且存在降价时段时,通知收藏用户 * * @param drops 降价站点列表(站点ID、名称、降价后新价格) * @return */ @PostMapping(value = "/site-favorite/inner/notifyPriceDrop") R<Void> notifyPriceDrop(@RequestBody List<SitePriceDropNotifyVO> drops); } ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/SitePriceDropNotifyVO.java
New file @@ -0,0 +1,24 @@ package com.ruoyi.account.api.vo; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import java.math.BigDecimal; /** * 站点降价推送通知(计费策略审批通过后触发) */ @Data @ApiModel(value = "SitePriceDropNotifyVO", description = "站点降价推送通知") public class SitePriceDropNotifyVO { @ApiModelProperty(value = "站点ID") private Integer siteId; @ApiModelProperty(value = "站点名称") private String siteName; @ApiModelProperty(value = "降价后新价格(新策略时段最低价)") private BigDecimal price; } ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserSiteFavoriteController.java
@@ -2,6 +2,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.ruoyi.account.api.model.TAppUserSiteFavorite; import com.ruoyi.account.api.vo.SitePriceDropNotifyVO; import com.ruoyi.account.dto.FavoriteSiteRequest; import com.ruoyi.account.service.TAppUserSiteFavoriteService; import com.ruoyi.chargingPile.api.feignClient.SiteClient; @@ -97,4 +98,11 @@ public AjaxResult<String> template() { return AjaxResult.success(priceDropTemplateId); } @PostMapping("/inner/notifyPriceDrop") @ApiOperation(value = "计费策略审批通过降价后通知收藏用户(内部接口)", tags = {"内部接口-收藏"}) public R<Void> notifyPriceDrop(@RequestBody List<SitePriceDropNotifyVO> drops) { favoriteService.notifyPriceDrop(drops); return R.ok(); } } ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TAppUserSiteFavoriteService.java
@@ -2,10 +2,19 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.ruoyi.account.api.model.TAppUserSiteFavorite; import com.ruoyi.account.api.vo.SitePriceDropNotifyVO; import java.math.BigDecimal; import java.util.List; public interface TAppUserSiteFavoriteService extends IService<TAppUserSiteFavorite> { void saveOrRestore(Long appUserId, Integer siteId, BigDecimal favoritePrice, Boolean notifyEnabled); /** * 计费策略审批通过且存在降价时段时,向开启降价提醒的收藏用户推送微信订阅消息 * * @param drops 降价站点列表(站点ID、名称、降价后新价格) */ void notifyPriceDrop(List<SitePriceDropNotifyVO> drops); } ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserSiteFavoriteServiceImpl.java
@@ -1,20 +1,131 @@ package com.ruoyi.account.service.impl; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ruoyi.account.api.model.TAppUser; import com.ruoyi.account.api.model.TAppUserSiteFavorite; import com.ruoyi.account.api.vo.SitePriceDropNotifyVO; import com.ruoyi.account.mapper.TAppUserSiteFavoriteMapper; import com.ruoyi.account.service.TAppUserService; import com.ruoyi.account.service.TAppUserSiteFavoriteService; import com.ruoyi.account.wx.model.WeixinProperties; import com.ruoyi.account.wx.tools.WxAppletTools; import com.ruoyi.common.core.utils.HttpUtils; import com.ruoyi.common.redis.service.RedisService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.RestTemplate; import javax.annotation.Resource; import java.math.BigDecimal; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; @Slf4j @Service public class TAppUserSiteFavoriteServiceImpl extends ServiceImpl<TAppUserSiteFavoriteMapper, TAppUserSiteFavorite> implements TAppUserSiteFavoriteService { private static final String SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token="; @Resource private TAppUserService appUserService; @Resource private RestTemplate wxRestTemplate; @Resource private WeixinProperties wxConfig; @Resource private RedisService redisService; @Value("${wx.applet.price-drop-template-id:7nCycRUZxQ2jxAb2UaYazjp2sPZy3qYyeLqVytyhHhY}") private String templateId; @Value("${wx.applet.price-drop-page:pagesMine/favoriteSite/favoriteSite}") private String page; @Override public void saveOrRestore(Long appUserId, Integer siteId, BigDecimal favoritePrice, Boolean notifyEnabled) { baseMapper.upsert(appUserId, siteId, favoritePrice, Boolean.TRUE.equals(notifyEnabled)); } @Override public void notifyPriceDrop(List<SitePriceDropNotifyVO> drops) { if (drops == null || drops.isEmpty() || !StringUtils.hasText(templateId)) { return; } List<Integer> siteIds = drops.stream().map(SitePriceDropNotifyVO::getSiteId) .filter(Objects::nonNull).distinct().collect(Collectors.toList()); if (siteIds.isEmpty()) { return; } Map<Integer, SitePriceDropNotifyVO> priceMap = drops.stream() .collect(Collectors.toMap(SitePriceDropNotifyVO::getSiteId, Function.identity(), (a, b) -> a)); List<TAppUserSiteFavorite> favorites = lambdaQuery() .eq(TAppUserSiteFavorite::getNotifyEnabled, true) .in(TAppUserSiteFavorite::getSiteId, siteIds) .list(); for (TAppUserSiteFavorite favorite : favorites) { SitePriceDropNotifyVO drop = priceMap.get(favorite.getSiteId()); if (drop == null || drop.getPrice() == null) { continue; } TAppUser user = appUserService.getById(favorite.getAppUserId()); if (user == null || !StringUtils.hasText(user.getWxOpenid())) { log.info("收藏站点降价提醒未发送,用户openid为空,userId={}, siteId={}", favorite.getAppUserId(), favorite.getSiteId()); continue; } if (sendSubscribeMessage(user.getWxOpenid(), favorite, drop)) { favorite.setNotifyEnabled(false); favorite.setLastNotifiedPrice(drop.getPrice()); updateById(favorite); log.info("收藏站点降价提醒发送成功,userId={}, siteId={}, price={}", favorite.getAppUserId(), favorite.getSiteId(), drop.getPrice()); } } } private boolean sendSubscribeMessage(String openId, TAppUserSiteFavorite favorite, SitePriceDropNotifyVO drop) { JSONObject body = new JSONObject(); body.put("touser", openId); body.put("template_id", templateId); body.put("page", page + "?siteId=" + favorite.getSiteId()); JSONObject data = new JSONObject(); data.put("thing1", value(limit(drop.getSiteName(), 20))); data.put("amount4", value(formatAmount(drop.getPrice()))); body.put("data", data); String accessToken = new WxAppletTools(wxRestTemplate, wxConfig, redisService).getAccessToken(""); String result = HttpUtils.post(SEND_URL + accessToken, body.toJSONString()); JSONObject response = JSON.parseObject(result); boolean success = response != null && response.getIntValue("errcode") == 0; if (!success) { log.warn("发送收藏站点降价提醒失败,userId={}, siteId={}, response={}", favorite.getAppUserId(), favorite.getSiteId(), result); } return success; } private JSONObject value(String value) { JSONObject item = new JSONObject(); item.put("value", value); return item; } private String formatAmount(BigDecimal amount) { return amount.setScale(2, BigDecimal.ROUND_HALF_UP).toPlainString() + "元"; } private String limit(String value, int length) { if (!StringUtils.hasText(value) || value.length() <= length) { return value; } return value.substring(0, length); } } ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/task/FavoritePriceDropTask.java
@@ -59,7 +59,8 @@ @Value("${wx.applet.price-drop-page:pagesMine/favoriteSite/favoriteSite}") private String page; @Scheduled(fixedDelay = 30 * 60 * 1000L, initialDelay = 2 * 60 * 1000L) // 降价推送已改为计费策略审批通过时触发(TAccountingStrategyController.authPass → TAppUserSiteFavoriteClient),此轮询任务暂停(如需回退恢复 @Scheduled 即可) // @Scheduled(fixedDelay = 30 * 60 * 1000L, initialDelay = 2 * 60 * 1000L) public void notifyPriceDrop() { if (!StringUtils.hasText(templateId)) { return; ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/controller/TAccountingStrategyController.java
@@ -4,7 +4,9 @@ import com.alibaba.fastjson.JSON; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.ruoyi.account.api.feignClient.TAppUserSiteFavoriteClient; import com.ruoyi.account.api.model.TAppUser; import com.ruoyi.account.api.vo.SitePriceDropNotifyVO; import com.ruoyi.chargingPile.api.dto.TAccountingStrategyDTO; import com.ruoyi.chargingPile.api.model.*; import com.ruoyi.chargingPile.api.query.BatchSetAccountingStrategy; @@ -35,6 +37,8 @@ import com.ruoyi.system.api.domain.SysUser; import com.ruoyi.system.api.feignClient.SysUserClient; import io.swagger.annotations.ApiOperation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; @@ -46,6 +50,7 @@ import java.math.RoundingMode; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeParseException; import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -65,6 +70,8 @@ @RestController @RequestMapping("/t-accounting-strategy") public class TAccountingStrategyController { private static final Logger log = LoggerFactory.getLogger(TAccountingStrategyController.class); private final TAccountingStrategyService accountingStrategyService; private final TAccountingStrategyDetailService accountingStrategyDetailService; @@ -88,6 +95,9 @@ @Resource private ChengDuJianGuanClient chengDuJianGuanClient; @Resource private TAppUserSiteFavoriteClient siteFavoriteClient; @Autowired private TokenService tokenService; @@ -507,6 +517,8 @@ if(!siteIds.isEmpty()){ tcecClient.notificationStationChange(siteIds); } // 审批通过且新旧策略对比存在收费降低的时段时,向开启降价提醒的收藏用户推送降价消息 notifySubscribersOnPriceDrop(one, byId, list); } chargingGunService.update(Wrappers.<TChargingGun>lambdaUpdate() .set(TChargingGun::getAccountingStrategyId, byId.getId()) @@ -547,6 +559,93 @@ } /** * 新旧计费策略时段对比,存在收费降低的时段时向受影响站点的降价提醒订阅用户推送微信消息。 * 推送为旁路操作,失败不影响审批主流程。 */ private void notifySubscribersOnPriceDrop(TAccountingStrategy oldStrategy, TAccountingStrategy newStrategy, List<Site> sites) { try { if (oldStrategy == null || newStrategy == null || sites == null || sites.isEmpty()) { return; } // 旧策略详情须在旧策略删除前查询 List<TAccountingStrategyDetail> oldDetails = accountingStrategyDetailService.list(new LambdaQueryWrapper<TAccountingStrategyDetail>() .eq(TAccountingStrategyDetail::getAccountingStrategyId, oldStrategy.getId()) .eq(TAccountingStrategyDetail::getDelFlag, 0)); List<TAccountingStrategyDetail> newDetails = accountingStrategyDetailService.list(new LambdaQueryWrapper<TAccountingStrategyDetail>() .eq(TAccountingStrategyDetail::getAccountingStrategyId, newStrategy.getId()) .eq(TAccountingStrategyDetail::getDelFlag, 0)); if (!hasPriceDrop(oldDetails, newDetails)) { return; } BigDecimal minPrice = newDetails.stream() .map(this::periodTotalPrice) .filter(Objects::nonNull) .min(BigDecimal::compareTo) .orElse(null); if (minPrice == null) { return; } List<SitePriceDropNotifyVO> drops = sites.stream().map(site -> { SitePriceDropNotifyVO vo = new SitePriceDropNotifyVO(); vo.setSiteId(site.getId()); vo.setSiteName(site.getName()); vo.setPrice(minPrice); return vo; }).collect(Collectors.toList()); siteFavoriteClient.notifyPriceDrop(drops); } catch (Exception e) { log.error("计费策略审批通过后降价推送失败,oldStrategyId={}, newStrategyId={}", oldStrategy == null ? null : oldStrategy.getId(), newStrategy == null ? null : newStrategy.getId(), e); } } /** 新旧时段按时间区间重叠对比,任一重叠时段新价低于旧价即视为降价(价格为 电价+服务费)。 */ private boolean hasPriceDrop(List<TAccountingStrategyDetail> oldDetails, List<TAccountingStrategyDetail> newDetails) { for (TAccountingStrategyDetail oldDetail : oldDetails) { BigDecimal oldPrice = periodTotalPrice(oldDetail); LocalTime oldStart = parsePeriodTime(oldDetail.getStartTime(), false); LocalTime oldEnd = parsePeriodTime(oldDetail.getEndTime(), true); if (oldPrice == null || oldStart == null || oldEnd == null) { continue; } for (TAccountingStrategyDetail newDetail : newDetails) { BigDecimal newPrice = periodTotalPrice(newDetail); LocalTime newStart = parsePeriodTime(newDetail.getStartTime(), false); LocalTime newEnd = parsePeriodTime(newDetail.getEndTime(), true); if (newPrice == null || newStart == null || newEnd == null) { continue; } if (oldStart.isBefore(newEnd) && newStart.isBefore(oldEnd) && newPrice.compareTo(oldPrice) < 0) { return true; } } } return false; } private BigDecimal periodTotalPrice(TAccountingStrategyDetail detail) { if (detail.getElectrovalence() == null || detail.getServiceCharge() == null) { return null; } return detail.getElectrovalence().add(detail.getServiceCharge()).setScale(4, BigDecimal.ROUND_HALF_UP); } /** 时段时间解析,endTime 为 "00:00" 时视为 23:59:59(与 SiteController.parsePriceTime 语义一致)。 */ private LocalTime parsePeriodTime(String value, boolean endTime) { if (value == null || value.isEmpty()) { return null; } if (endTime && "00:00".equals(value)) { return LocalTime.of(23, 59, 59); } try { return LocalTime.parse(value.length() == 5 ? value + ":00" : value); } catch (DateTimeParseException e) { return null; } } @ApiOperation(tags = {"后台-申请表单-计费模板审核"},value = "删除") @DeleteMapping(value = "/delete") @Log(title = "【计费模板审核】删除计费模板审核", businessType = BusinessType.DELETE)