longz
2026-08-06 35d3dc45fbdc65eb23d5cc754134fbf240419f86
feat: 收藏降价推送改为计费策略审批通过时触发

旧机制:FavoritePriceDropTask 每30分钟轮询,对比站点当前价与订阅时快照价,
低于快照即推(与时段切换/策略变更无关)。新机制:计费策略两级审批通过且
新旧策略时段对比存在收费降低的时段时,向开启降价提醒的收藏用户推送微信订阅消息。

- ruoyi-api-account: 新增 SitePriceDropNotifyVO + TAppUserSiteFavoriteClient(Feign) + Fallback
- ruoyi-account: TAppUserSiteFavoriteService.notifyPriceDrop 发送逻辑(复用旧任务模板/发送代码),
Controller 新增内部端点 /site-favorite/inner/notifyPriceDrop;推送成功仍关闭订阅(notifyEnabled=false)
- ruoyi-chargingPile: authPass 二级通过+替换旧策略时,新旧时段按时间区间重叠对比
(价格为 电价+服务费,endTime 00:00 视为 23:59:59),有降价则按新策略最低价推送,
推送为旁路失败不影响审批;FavoritePriceDropTask 停用 @Scheduled 便于回退

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3个文件已添加
5个文件已修改
313 ■■■■■ 已修改文件
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/TAppUserSiteFavoriteFallbackFactory.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/TAppUserSiteFavoriteClient.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/SitePriceDropNotifyVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserSiteFavoriteController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TAppUserSiteFavoriteService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserSiteFavoriteServiceImpl.java 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/task/FavoritePriceDropTask.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/controller/TAccountingStrategyController.java 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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)