shinnosuke
2026-07-13 193f08a3a815ed0cf4567bdbc38ba317dd9ec57c
feat(charging): 实现扫码充电V2.0预约功能和VIN校验

- 新增站点预约时间配置字段,支持设置允许预约开始和结束时间
- 添加车辆VIN码存储和校验功能,在用户车辆信息中增加VIN字段
- 实现充电订单中预约时间和VIN信息记录,支持预期VIN和实际VIN对比
- 创建合作商绑定小程序用户关联表,支持合作商业务管理
- 开发站点收藏功能,用户可以收藏站点并设置降价提醒
- 实现收藏站点价格监控和微信订阅消息推送机制
- 添加VIN码识别和验证流程,确保充电车辆身份准确性
- 优化站点查询接口,增加VIN标识和预约相关字段返回
- 完善合作伙伴管理功能,支持绑定用户查询和管理操作
- 更新数据库表结构,增加预约、VIN、收藏相关数据表和字段
17个文件已添加
24个文件已修改
1207 ■■■■■ 已修改文件
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/AppUserInfoDto.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/AppUserFallbackFactory.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/AppUserClient.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TAppUserCar.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TAppUserSiteFavorite.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/factory/PartnerFallbackFactory.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/factory/SiteFallbackFactory.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/feignClient/PartnerClient.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/feignClient/SiteClient.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/model/Site.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/model/TPartnerAppUser.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/vo/FavoriteSitePriceVO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/vo/SiteInfoVO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-integration/src/main/java/com/ruoyi/integration/api/model/SecurityDetection.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TChargingOrder.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/SecurityDetectionVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppCouponController.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserSiteFavoriteController.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/dto/FavoriteSiteRequest.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserSiteFavoriteMapper.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TAppUserSiteFavoriteService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserSiteFavoriteServiceImpl.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/task/FavoritePriceDropTask.java 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TAppUserCarMapper.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/controller/PartnerController.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/controller/SiteController.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/dto/PartnerAppUserBindDTO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/dto/PartnerAppUserVO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/mapper/TPartnerAppUserMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/service/TPartnerAppUserService.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/service/impl/TPartnerAppUserServiceImpl.java 190 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/resources/mapper/chargingPile/SiteMapper.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/model/SecurityDetectionMessage.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/AddChargingOrder.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/MyChargingOrderList.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/PreChargeCheck.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java 215 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260711_scan_reservation_vin.sql 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260713_partner_app_user.sql 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260713_site_favorite.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/AppUserInfoDto.java
@@ -35,6 +35,8 @@
    private Integer isPartnerUser;
    @ApiModelProperty("加盟商ID")
    private Integer partnerId;
    @ApiModelProperty("加盟商名称")
    private String partnerName;
    @ApiModelProperty("当前绑定的车辆")
    private List<TAppUserCar> userCars;
}
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/AppUserFallbackFactory.java
@@ -1,5 +1,6 @@
package com.ruoyi.account.api.factory;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.api.dto.GiveVipDto;
import com.ruoyi.account.api.dto.SendMessageDTO;
import com.ruoyi.account.api.dto.UploadPdfDTO;
@@ -85,6 +86,11 @@
            }
            @Override
            public R<Page<TAppUser>> searchPartnerBindUsers(String phone, Integer pageNum, Integer pageSize) {
                return R.fail("查询合作商可绑定用户失败:" + throwable.getMessage());
            }
            @Override
            public R refundPoints(String param) {
                return R.fail("后台兑换订单退款回退用户积分失败:"+throwable.getMessage());
            }
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/AppUserClient.java
@@ -1,5 +1,6 @@
package com.ruoyi.account.api.feignClient;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.api.dto.GiveVipDto;
import com.ruoyi.account.api.dto.SendMessageDTO;
import com.ruoyi.account.api.dto.UploadPdfDTO;
@@ -88,6 +89,11 @@
     */
    @GetMapping("/t-app-user/user/selectByPhoneLike")
    R<List<TAppUser>> selectByPhoneLike(@RequestParam("phone") String phone);
    @GetMapping("/t-app-user/user/searchPartnerBindUsers")
    R<Page<TAppUser>> searchPartnerBindUsers(@RequestParam(value = "phone", required = false) String phone,
                                             @RequestParam("pageNum") Integer pageNum,
                                             @RequestParam("pageSize") Integer pageSize);
    /**
     * 远程调用 积分兑换订单 退款回退积分
     * @return
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TAppUserCar.java
@@ -45,6 +45,10 @@
    @TableField("license_plate")
    private String licensePlate;
    @ApiModelProperty(value = "车辆VIN码")
    @TableField("vin")
    private String vin;
    @ApiModelProperty(value = "车牌")
    @TableField("pic")
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TAppUserSiteFavorite.java
New file
@@ -0,0 +1,42 @@
package com.ruoyi.account.api.model;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.ruoyi.common.core.web.domain.BasePojo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("t_app_user_site_favorite")
@ApiModel(value = "用户收藏站点")
public class TAppUserSiteFavorite extends BasePojo {
    @TableId("id")
    private Long id;
    @ApiModelProperty("小程序用户ID")
    @TableField("app_user_id")
    private Long appUserId;
    @ApiModelProperty("站点ID")
    @TableField("site_id")
    private Integer siteId;
    @ApiModelProperty("收藏时电价")
    @TableField("favorite_price")
    private BigDecimal favoritePrice;
    @ApiModelProperty("是否允许降价提醒")
    @TableField("notify_enabled")
    private Boolean notifyEnabled;
    @ApiModelProperty("最近一次已提醒价格")
    @TableField("last_notified_price")
    private BigDecimal lastNotifiedPrice;
}
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/factory/PartnerFallbackFactory.java
@@ -30,6 +30,11 @@
            public R<Partner> getPartnerById(Integer id) {
                return R.fail("根据id获取合作商信息失败:" + throwable.getMessage());
            }
            @Override
            public R<Partner> getPartnerByAppUserId(Long appUserId) {
                return R.fail("根据小程序用户获取合作商信息失败:" + throwable.getMessage());
            }
            
            @Override
            public R<Set<Integer>> authSite(Integer partnerId, String siteMenu) {
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/factory/SiteFallbackFactory.java
@@ -5,6 +5,7 @@
import com.ruoyi.chargingPile.api.model.Partner;
import com.ruoyi.chargingPile.api.model.Site;
import com.ruoyi.chargingPile.api.model.TChargingPile;
import com.ruoyi.chargingPile.api.vo.FavoriteSitePriceVO;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.web.page.PageInfo;
import org.slf4j.Logger;
@@ -44,6 +45,11 @@
            public R<List<Site>> getSiteByIds(List<Integer> ids) {
                return R.fail("根据id集合获取站点数据调用失败:" + throwable.getMessage());
            }
            @Override
            public R<List<FavoriteSitePriceVO>> getFavoriteSitePrices(List<Integer> ids) {
                return R.fail("获取收藏站点价格失败:" + throwable.getMessage());
            }
    
            @Override
            public R<List<Site>> getSiteAll() {
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/feignClient/PartnerClient.java
@@ -28,6 +28,9 @@
     */
    @PostMapping("/partner/getPartnerById/{id}")
    R<Partner> getPartnerById(@PathVariable("id") Integer id);
    @PostMapping("/partner/getPartnerByAppUserId/{appUserId}")
    R<Partner> getPartnerByAppUserId(@PathVariable("appUserId") Long appUserId);
    
    
    /**
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/feignClient/SiteClient.java
@@ -5,6 +5,7 @@
import com.ruoyi.chargingPile.api.model.Partner;
import com.ruoyi.chargingPile.api.model.Site;
import com.ruoyi.chargingPile.api.model.TChargingPile;
import com.ruoyi.chargingPile.api.vo.FavoriteSitePriceVO;
import com.ruoyi.common.core.constant.ServiceNameConstants;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.web.page.PageInfo;
@@ -33,6 +34,9 @@
     */
    @PostMapping("/site/getSiteByIds")
    R<List<Site>> getSiteByIds(@RequestBody List<Integer> ids);
    @PostMapping("/site/favoriteSitePrices")
    R<List<FavoriteSitePriceVO>> getFavoriteSitePrices(@RequestBody List<Integer> ids);
    
    /**
     * 获取所有站点
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/model/Site.java
@@ -453,6 +453,12 @@
    @TableField("supportOrder")
    @ApiModelProperty(value = "2.0修改字段-是否支持预约 0不支持1支持")
    private Integer supportOrder;
    @TableField("reservation_start_time")
    @ApiModelProperty(value = "允许预约开始时间,格式HH:mm")
    private String reservationStartTime;
    @TableField("reservation_end_time")
    @ApiModelProperty(value = "允许预约结束时间,格式HH:mm")
    private String reservationEndTime;
    /**
     * 高速路服务区编号
     */
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/model/TPartnerAppUser.java
New file
@@ -0,0 +1,40 @@
package com.ruoyi.chargingPile.api.model;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
 * 合作商与小程序用户绑定关系。
 */
@Data
@TableName("t_partner_app_user")
@ApiModel(value = "TPartnerAppUser对象", description = "合作商与小程序用户绑定关系")
public class TPartnerAppUser implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    @ApiModelProperty("主键")
    private Long id;
    @TableField("partner_id")
    @ApiModelProperty("合作商ID")
    private Integer partnerId;
    @TableField("app_user_id")
    @ApiModelProperty("小程序用户ID")
    private Long appUserId;
    @TableField(value = "create_time", fill = FieldFill.INSERT)
    @ApiModelProperty("创建时间")
    private LocalDateTime createTime;
}
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/vo/FavoriteSitePriceVO.java
New file
@@ -0,0 +1,13 @@
package com.ruoyi.chargingPile.api.vo;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class FavoriteSitePriceVO {
    private Integer siteId;
    private String siteName;
    private Integer status;
    private BigDecimal currentPrice;
}
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/vo/SiteInfoVO.java
@@ -35,10 +35,21 @@
    @ApiModelProperty(value = "充电模式(1=超级快充,2=快充,3=慢充")
    private Integer chargeMode;
    @ApiModelProperty(value = "是否支持预约")
    private Integer supportOrder;
    @ApiModelProperty(value = "允许预约开始时间,格式HH:mm")
    private String reservationStartTime;
    @ApiModelProperty(value = "允许预约结束时间,格式HH:mm")
    private String reservationEndTime;
    @ApiModelProperty(value = "是否支持VIN码识别")
    private Integer vinFlag;
    @ApiModelProperty(value = "上次选择车辆logo")
    private String carLogo;
    @ApiModelProperty(value = "上次选择车辆车牌号")
    private String licensePlate;
    @ApiModelProperty(value = "上次选择车辆VIN")
    private String vin;
    @ApiModelProperty(value = "上次选择车辆品牌")
    private String vehicleBrand;
    @ApiModelProperty(value = "上次选择车辆id")
ruoyi-api/ruoyi-api-integration/src/main/java/com/ruoyi/integration/api/model/SecurityDetection.java
@@ -15,5 +15,6 @@
    private Integer secure_connection; // 车枪连接(0=未连接,1=连接)
    private Integer nsulation_detection; // 绝缘检测(0=安全,1=不安全)
    private Integer electronic_lock_lock; // 电磁锁状态(0=未锁,1=已锁)
    private String vin; // 当前车辆VIN码
}
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TChargingOrder.java
@@ -89,6 +89,18 @@
    @TableField("plate_num")
    private String plateNum;
    @ApiModelProperty(value = "预约充电时间")
    @TableField("reservation_time")
    private LocalDateTime reservationTime;
    @ApiModelProperty(value = "支付车辆VIN快照")
    @TableField("expected_vin")
    private String expectedVin;
    @ApiModelProperty(value = "启动时设备识别VIN")
    @TableField("actual_vin")
    private String actualVin;
    @ApiModelProperty(value = "站点 id")
    @TableField("site_id")
    private Integer siteId;
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/SecurityDetectionVO.java
@@ -10,5 +10,6 @@
    private Integer secure_connection; // 车枪连接(0=未连接,1=连接)
    private Integer nsulation_detection; // 绝缘检测(0=安全,1=不安全)
    private Integer electronic_lock_lock; // 电磁锁状态(0=未锁,1=已锁)
    private String vin; // 当前车辆VIN码
}
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppCouponController.java
@@ -157,6 +157,7 @@
                    .orderByDesc("create_time"));
            if (!cars.isEmpty()){
                data.setLicensePlate(cars.get(0).getLicensePlate());
                data.setVin(cars.get(0).getVin());
                data.setVehicleBrand(cars.get(0).getVehicleBrand());
                data.setId(cars.get(0).getId().toString());
            }
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserController.java
@@ -17,6 +17,8 @@
import com.ruoyi.account.util.*;
import com.ruoyi.account.util.carBrand.CarBrandUtil;
import com.ruoyi.account.wx.model.WeixinProperties;
import com.ruoyi.chargingPile.api.feignClient.PartnerClient;
import com.ruoyi.chargingPile.api.model.Partner;
import com.ruoyi.common.core.constant.Constants;
import com.ruoyi.common.core.constant.MsgConstants;
import com.ruoyi.common.core.domain.R;
@@ -153,6 +155,8 @@
    private GiveVipUtil giveVipUtil;
    @Resource
    private TAppUserWhitelistService appUserWhitelistService;
    @Resource
    private PartnerClient partnerClient;
    @Autowired
    private RedissonClient redissonClient;
@@ -921,16 +925,35 @@
        appUserInfoDto.setFirstAdd(byId.getFirstAdd()!=null?byId.getFirstAdd():0);
        appUserInfoDto.setPoints(byId.getPoints()==null?0:byId.getPoints());
        appUserInfoDto.setBalance(byId.getBalance()==null?BigDecimal.ZERO:byId.getBalance());
        TAppUserWhitelist partnerWhitelist = appUserWhitelistService.lambdaQuery()
                .eq(TAppUserWhitelist::getAppUserId, userId)
                .eq(TAppUserWhitelist::getScopeType, 3)
                .eq(TAppUserWhitelist::getStatus, 1)
                .eq(TAppUserWhitelist::getDelFlag, false)
                .isNotNull(TAppUserWhitelist::getPartnerId)
                .last("limit 1")
                .one();
        appUserInfoDto.setIsPartnerUser(partnerWhitelist == null ? 0 : 1);
        appUserInfoDto.setPartnerId(partnerWhitelist == null ? null : partnerWhitelist.getPartnerId());
        Partner boundPartner = null;
        try {
            R<Partner> partnerResult = partnerClient.getPartnerByAppUserId(userId);
            boundPartner = partnerResult == null ? null : partnerResult.getData();
        } catch (Exception e) {
            log.warn("查询小程序用户绑定合作商失败,继续兼容旧白名单关系: {}", e.getMessage());
        }
        Integer partnerId = boundPartner == null ? null : boundPartner.getId();
        String partnerName = boundPartner == null ? null : boundPartner.getName();
        if (partnerId == null) {
            TAppUserWhitelist partnerWhitelist = appUserWhitelistService.lambdaQuery()
                    .eq(TAppUserWhitelist::getAppUserId, userId)
                    .eq(TAppUserWhitelist::getScopeType, 3)
                    .eq(TAppUserWhitelist::getStatus, 1)
                    .eq(TAppUserWhitelist::getDelFlag, false)
                    .isNotNull(TAppUserWhitelist::getPartnerId)
                    .last("limit 1")
                    .one();
            if (partnerWhitelist != null) {
                partnerId = partnerWhitelist.getPartnerId();
                R<Partner> legacyPartnerResult = partnerClient.getPartnerById(partnerId);
                Partner legacyPartner = legacyPartnerResult == null ? null : legacyPartnerResult.getData();
                partnerName = legacyPartner == null ? null : legacyPartner.getName();
            }
        }
        appUserInfoDto.setIsPartnerUser(partnerId == null ? 0 : 1);
        appUserInfoDto.setPartnerId(partnerId);
        appUserInfoDto.setPartnerName(partnerName);
        return R.ok(appUserInfoDto);
    }
    
@@ -1220,6 +1243,18 @@
        return R.ok(appUserService.list(Wrappers.lambdaQuery(TAppUser.class)
                .eq(TAppUser::getPhone,phone)));
    }
    @GetMapping("/user/searchPartnerBindUsers")
    public R<Page<TAppUser>> searchPartnerBindUsers(@RequestParam(value = "phone", required = false) String phone,
                                                     @RequestParam(defaultValue = "1") Integer pageNum,
                                                     @RequestParam(defaultValue = "10") Integer pageSize) {
        Page<TAppUser> page = appUserService.lambdaQuery()
                .like(StringUtils.isNotEmpty(phone), TAppUser::getPhone, phone)
                .orderByDesc(BasePojo::getCreateTime)
                .page(Page.of(pageNum, pageSize));
        page.getRecords().forEach(user -> user.setUid(user.getId().toString()));
        return R.ok(page);
    }
    
    @Log(title = "【我的】签到", businessType = BusinessType.INSERT,operatorType = OperatorType.MOBILE)
    @ApiOperation(value = "签到", tags = {"小程序-个人中心-签到"})
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppUserSiteFavoriteController.java
New file
@@ -0,0 +1,97 @@
package com.ruoyi.account.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.account.api.model.TAppUserSiteFavorite;
import com.ruoyi.account.dto.FavoriteSiteRequest;
import com.ruoyi.account.service.TAppUserSiteFavoriteService;
import com.ruoyi.chargingPile.api.feignClient.SiteClient;
import com.ruoyi.chargingPile.api.vo.FavoriteSitePriceVO;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.security.service.TokenService;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
@RestController
@RequestMapping("/site-favorite")
public class TAppUserSiteFavoriteController {
    @Resource
    private TAppUserSiteFavoriteService favoriteService;
    @Resource
    private TokenService tokenService;
    @Resource
    private SiteClient siteClient;
    @Value("${wx.applet.price-drop-template-id:}")
    private String priceDropTemplateId;
    @GetMapping("/list")
    @ApiOperation(value = "查询当前用户收藏站点", tags = {"小程序-收藏"})
    public AjaxResult<List<TAppUserSiteFavorite>> list() {
        Long userId = tokenService.getLoginUserApplet().getUserId();
        return AjaxResult.success(favoriteService.list(new LambdaQueryWrapper<TAppUserSiteFavorite>()
                .eq(TAppUserSiteFavorite::getAppUserId, userId)
                .orderByDesc(TAppUserSiteFavorite::getCreateTime)));
    }
    @GetMapping("/check/{siteId}")
    @ApiOperation(value = "检查站点是否已收藏", tags = {"小程序-收藏"})
    public AjaxResult<Boolean> check(@PathVariable Integer siteId) {
        Long userId = tokenService.getLoginUserApplet().getUserId();
        return AjaxResult.success(favoriteService.count(new LambdaQueryWrapper<TAppUserSiteFavorite>()
                .eq(TAppUserSiteFavorite::getAppUserId, userId)
                .eq(TAppUserSiteFavorite::getSiteId, siteId)) > 0);
    }
    @PostMapping
    @ApiOperation(value = "收藏站点", tags = {"小程序-收藏"})
    public AjaxResult<Void> save(@Validated @RequestBody FavoriteSiteRequest request) {
        Long userId = tokenService.getLoginUserApplet().getUserId();
        R<List<FavoriteSitePriceVO>> priceResponse = siteClient.getFavoriteSitePrices(
                Collections.singletonList(request.getSiteId()));
        if (priceResponse == null || priceResponse.getData() == null || priceResponse.getData().isEmpty()
                || priceResponse.getData().get(0).getCurrentPrice() == null) {
            return AjaxResult.error("站点当前价格不可用,暂时无法收藏");
        }
        favoriteService.saveOrRestore(userId, request.getSiteId(),
                priceResponse.getData().get(0).getCurrentPrice(), request.getNotifyEnabled());
        return AjaxResult.success();
    }
    @DeleteMapping("/{siteId}")
    @ApiOperation(value = "取消收藏站点", tags = {"小程序-收藏"})
    public AjaxResult<Void> delete(@PathVariable Integer siteId) {
        Long userId = tokenService.getLoginUserApplet().getUserId();
        favoriteService.remove(new LambdaQueryWrapper<TAppUserSiteFavorite>()
                .eq(TAppUserSiteFavorite::getAppUserId, userId)
                .eq(TAppUserSiteFavorite::getSiteId, siteId));
        return AjaxResult.success();
    }
    @PostMapping("/{siteId}/subscribe")
    @ApiOperation(value = "开启站点降价提醒", tags = {"小程序-收藏"})
    public AjaxResult<Void> subscribe(@PathVariable Integer siteId) {
        Long userId = tokenService.getLoginUserApplet().getUserId();
        favoriteService.lambdaUpdate()
                .eq(TAppUserSiteFavorite::getAppUserId, userId)
                .eq(TAppUserSiteFavorite::getSiteId, siteId)
                .set(TAppUserSiteFavorite::getNotifyEnabled, true)
                .update();
        return AjaxResult.success();
    }
    @GetMapping("/template")
    @ApiOperation(value = "获取降价提醒模板ID", tags = {"小程序-收藏"})
    public AjaxResult<String> template() {
        return AjaxResult.success(priceDropTemplateId);
    }
}
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/dto/FavoriteSiteRequest.java
New file
@@ -0,0 +1,24 @@
package com.ruoyi.account.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
@Data
@ApiModel("收藏站点请求")
public class FavoriteSiteRequest {
    @NotNull(message = "站点ID不能为空")
    @ApiModelProperty("站点ID")
    private Integer siteId;
    @NotNull(message = "收藏价格不能为空")
    @ApiModelProperty("收藏时电价")
    private BigDecimal favoritePrice;
    @ApiModelProperty("是否允许降价提醒")
    private Boolean notifyEnabled;
}
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TAppUserSiteFavoriteMapper.java
New file
@@ -0,0 +1,22 @@
package com.ruoyi.account.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.account.api.model.TAppUserSiteFavorite;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
public interface TAppUserSiteFavoriteMapper extends BaseMapper<TAppUserSiteFavorite> {
    @Insert("INSERT INTO t_app_user_site_favorite " +
            "(app_user_id, site_id, favorite_price, notify_enabled, create_time, del_flag) " +
            "VALUES (#{appUserId}, #{siteId}, #{favoritePrice}, #{notifyEnabled}, NOW(), 0) " +
            "ON DUPLICATE KEY UPDATE favorite_price = VALUES(favorite_price), " +
            "notify_enabled = VALUES(notify_enabled), last_notified_price = NULL, " +
            "create_time = NOW(), del_flag = 0")
    int upsert(@Param("appUserId") Long appUserId,
               @Param("siteId") Integer siteId,
               @Param("favoritePrice") BigDecimal favoritePrice,
               @Param("notifyEnabled") Boolean notifyEnabled);
}
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TAppUserSiteFavoriteService.java
New file
@@ -0,0 +1,11 @@
package com.ruoyi.account.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.account.api.model.TAppUserSiteFavorite;
import java.math.BigDecimal;
public interface TAppUserSiteFavoriteService extends IService<TAppUserSiteFavorite> {
    void saveOrRestore(Long appUserId, Integer siteId, BigDecimal favoritePrice, Boolean notifyEnabled);
}
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserSiteFavoriteServiceImpl.java
New file
@@ -0,0 +1,20 @@
package com.ruoyi.account.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.api.model.TAppUserSiteFavorite;
import com.ruoyi.account.mapper.TAppUserSiteFavoriteMapper;
import com.ruoyi.account.service.TAppUserSiteFavoriteService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class TAppUserSiteFavoriteServiceImpl
        extends ServiceImpl<TAppUserSiteFavoriteMapper, TAppUserSiteFavorite>
        implements TAppUserSiteFavoriteService {
    @Override
    public void saveOrRestore(Long appUserId, Integer siteId, BigDecimal favoritePrice, Boolean notifyEnabled) {
        baseMapper.upsert(appUserId, siteId, favoritePrice, Boolean.TRUE.equals(notifyEnabled));
    }
}
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/task/FavoritePriceDropTask.java
New file
@@ -0,0 +1,149 @@
package com.ruoyi.account.task;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.account.api.model.TAppUser;
import com.ruoyi.account.api.model.TAppUserSiteFavorite;
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.chargingPile.api.feignClient.SiteClient;
import com.ruoyi.chargingPile.api.vo.FavoriteSitePriceVO;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.utils.HttpUtils;
import com.ruoyi.common.redis.service.RedisService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
@Slf4j
@Component
public class FavoritePriceDropTask {
    private static final String SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=";
    @Resource
    private TAppUserSiteFavoriteService favoriteService;
    @Resource
    private TAppUserService appUserService;
    @Resource
    private SiteClient siteClient;
    @Resource
    private RestTemplate wxRestTemplate;
    @Resource
    private WeixinProperties wxConfig;
    @Resource
    private RedisService redisService;
    @Resource
    private RedissonClient redissonClient;
    @Value("${wx.applet.price-drop-template-id:}")
    private String templateId;
    @Value("${wx.applet.price-drop-page:pagesMine/myCollection/myCollection}")
    private String page;
    @Scheduled(fixedDelay = 30 * 60 * 1000L, initialDelay = 2 * 60 * 1000L)
    public void notifyPriceDrop() {
        if (!StringUtils.hasText(templateId)) {
            return;
        }
        RLock lock = redissonClient.getLock("FAVORITE_SITE_PRICE_DROP_TASK");
        boolean locked = false;
        try {
            locked = lock.tryLock(0, 10, TimeUnit.MINUTES);
            if (!locked) {
                return;
            }
            List<TAppUserSiteFavorite> favorites = favoriteService.lambdaQuery()
                    .eq(TAppUserSiteFavorite::getNotifyEnabled, true)
                    .list();
            if (favorites.isEmpty()) {
                return;
            }
            List<Integer> siteIds = favorites.stream().map(TAppUserSiteFavorite::getSiteId)
                    .filter(Objects::nonNull).distinct().collect(Collectors.toList());
            R<List<FavoriteSitePriceVO>> response = siteClient.getFavoriteSitePrices(siteIds);
            List<FavoriteSitePriceVO> prices = response == null || response.getData() == null
                    ? Collections.emptyList() : response.getData();
            Map<Integer, FavoriteSitePriceVO> priceMap = prices.stream()
                    .collect(Collectors.toMap(FavoriteSitePriceVO::getSiteId, Function.identity(), (a, b) -> a));
            for (TAppUserSiteFavorite favorite : favorites) {
                FavoriteSitePriceVO price = priceMap.get(favorite.getSiteId());
                if (price == null || price.getCurrentPrice() == null || favorite.getFavoritePrice() == null
                        || price.getCurrentPrice().compareTo(favorite.getFavoritePrice()) >= 0) {
                    continue;
                }
                TAppUser user = appUserService.getById(favorite.getAppUserId());
                if (user == null || !StringUtils.hasText(user.getWxOpenid())) {
                    continue;
                }
                if (sendSubscribeMessage(user.getWxOpenid(), favorite, price)) {
                    favorite.setNotifyEnabled(false);
                    favorite.setLastNotifiedPrice(price.getCurrentPrice());
                    favoriteService.updateById(favorite);
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            log.error("收藏站点降价提醒任务执行失败", e);
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    private boolean sendSubscribeMessage(String openId, TAppUserSiteFavorite favorite, FavoriteSitePriceVO price) {
        BigDecimal decrease = favorite.getFavoritePrice().subtract(price.getCurrentPrice());
        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(price.getSiteName(), 20)));
        data.put("amount2", value(favorite.getFavoritePrice().setScale(4, BigDecimal.ROUND_HALF_UP).toPlainString()));
        data.put("amount3", value(price.getCurrentPrice().setScale(4, BigDecimal.ROUND_HALF_UP).toPlainString()));
        data.put("amount4", value(decrease.setScale(4, BigDecimal.ROUND_HALF_UP).toPlainString()));
        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 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/resources/mapper/account/TAppUserCarMapper.xml
@@ -7,6 +7,7 @@
        <id column="id" property="id" />
        <result column="app_user_id" property="appUserId" />
        <result column="license_plate" property="licensePlate" />
        <result column="vin" property="vin" />
        <result column="vehicle_brand" property="vehicleBrand" />
        <result column="vehicle_model" property="vehicleModel" />
        <result column="vehicle_use" property="vehicleUse" />
@@ -17,7 +18,7 @@
    <!-- 通用查询结果列 -->
    <sql id="Base_Column_List">
        id, app_user_id, license_plate, vehicle_brand, vehicle_model, vehicle_use, endurance, create_time, del_flag
        id, app_user_id, license_plate, vin, vehicle_brand, vehicle_model, vehicle_use, endurance, create_time, del_flag
    </sql>
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/controller/PartnerController.java
@@ -9,8 +9,11 @@
import com.ruoyi.chargingPile.api.query.GetPartnerList;
import com.ruoyi.chargingPile.api.query.SetPermissionConfiguration;
import com.ruoyi.chargingPile.api.domain.SiteMenu;
import com.ruoyi.chargingPile.dto.PartnerAppUserBindDTO;
import com.ruoyi.chargingPile.dto.PartnerAppUserVO;
import com.ruoyi.chargingPile.dto.ResetPassword;
import com.ruoyi.chargingPile.service.IPartnerService;
import com.ruoyi.chargingPile.service.TPartnerAppUserService;
import com.ruoyi.chargingPile.service.TPartnerSiteService;
import com.ruoyi.chargingPile.service.TSiteMenuService;
import com.ruoyi.common.core.domain.R;
@@ -54,6 +57,8 @@
    @Resource
    private TPartnerSiteService partnerSiteService;
    @Resource
    private TPartnerAppUserService partnerAppUserService;
    
    
    
@@ -98,6 +103,11 @@
    public R<Partner> getPartnerR(@PathVariable("id") Integer id){
        Partner partner = partnerService.getPartner(id);
        return R.ok(partner);
    }
    @ResponseBody
    @PostMapping("/getPartnerByAppUserId/{appUserId}")
    public R<Partner> getPartnerByAppUserId(@PathVariable("appUserId") Long appUserId) {
        return R.ok(partnerAppUserService.getPartnerByAppUserId(appUserId));
    }
    @ResponseBody
    @GetMapping("/getAllPartner")
@@ -149,6 +159,32 @@
    public AjaxResult resetPassword(@RequestBody ResetPassword resetPassword){
        return partnerService.resetPassword(resetPassword);
    }
    @RequiresPermissions(value = {"/partners/update"}, logical = Logical.OR)
    @GetMapping("/appUser/list")
    @ApiOperation(value = "查询合作商可绑定的小程序用户", tags = {"管理后台-合作商管理"})
    public AjaxResult<PageInfo<PartnerAppUserVO>> getPartnerAppUserList(@RequestParam Integer partnerId,
            @RequestParam(required = false) String phone,
            @RequestParam(defaultValue = "1") Integer pageNum,
            @RequestParam(defaultValue = "10") Integer pageSize) {
        return AjaxResult.success(partnerAppUserService.searchUsers(partnerId, phone, pageNum, pageSize));
    }
    @RequiresPermissions(value = {"/partners/update"}, logical = Logical.OR)
    @GetMapping("/appUser/bound/{partnerId}")
    @ApiOperation(value = "查询合作商已绑定的小程序用户", tags = {"管理后台-合作商管理"})
    public AjaxResult<List<PartnerAppUserVO>> getBoundPartnerAppUsers(@PathVariable Integer partnerId) {
        return AjaxResult.success(partnerAppUserService.getBoundUsers(partnerId));
    }
    @RequiresPermissions(value = {"/partners/update"}, logical = Logical.OR)
    @PostMapping("/appUser/bind")
    @ApiOperation(value = "保存合作商绑定的小程序用户", tags = {"管理后台-合作商管理"})
    @Log(title = "【合作商列表】绑定小程序用户", businessType = BusinessType.UPDATE)
    public AjaxResult bindPartnerAppUsers(@RequestBody PartnerAppUserBindDTO bindDTO) {
        partnerAppUserService.saveBindings(bindDTO);
        return AjaxResult.success();
    }
    
    @Autowired
private TokenService tokenService;
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/controller/SiteController.java
@@ -193,6 +193,10 @@
        siteInfoVO.setName(site.getName());
        siteInfoVO.setNumber(one.getNumber().toString());
        siteInfoVO.setSpaceChargeExplain(site.getSpaceChargeExplain());
        siteInfoVO.setSupportOrder(site.getSupportOrder());
        siteInfoVO.setReservationStartTime(site.getReservationStartTime());
        siteInfoVO.setReservationEndTime(site.getReservationEndTime());
        siteInfoVO.setVinFlag(one.getVinFlag());
        return R.ok(siteInfoVO);
    }
    /**
@@ -265,6 +269,10 @@
        siteInfoVO.setName(site.getName());
        siteInfoVO.setNumber(one.getNumber().toString());
        siteInfoVO.setSpaceChargeExplain(site.getSpaceChargeExplain());
        siteInfoVO.setSupportOrder(site.getSupportOrder());
        siteInfoVO.setReservationStartTime(site.getReservationStartTime());
        siteInfoVO.setReservationEndTime(site.getReservationEndTime());
        siteInfoVO.setVinFlag(one.getVinFlag());
        return R.ok(siteInfoVO);
    }
    
@@ -641,6 +649,70 @@
        List<Site> sites = siteService.list(new LambdaQueryWrapper<Site>().in(Site::getId, ids).eq(Site::getDelFlag, 0));
        return R.ok(sites);
    }
    @GetMapping("/getSitesByIds")
    @ApiOperation(value = "小程序批量获取站点基础信息", tags = {"小程序-收藏"})
    public R<List<Site>> getSitesByIds(@RequestParam String ids) {
        List<Integer> siteIds = Arrays.stream(ids.split(","))
                .filter(StringUtils::isNotEmpty)
                .map(Integer::valueOf)
                .collect(Collectors.toList());
        if (siteIds.isEmpty()) {
            return R.ok(Collections.emptyList());
        }
        return R.ok(siteService.list(new LambdaQueryWrapper<Site>()
                .in(Site::getId, siteIds)
                .eq(Site::getDelFlag, 0)));
    }
    @PostMapping("/favoriteSitePrices")
    @ApiOperation(value = "批量获取收藏站点当前价格", tags = {"内部接口-收藏"})
    public R<List<FavoriteSitePriceVO>> favoriteSitePrices(@RequestBody List<Integer> ids) {
        if (CollectionUtils.isEmpty(ids)) {
            return R.ok(Collections.emptyList());
        }
        List<Site> sites = siteService.list(new LambdaQueryWrapper<Site>()
                .in(Site::getId, ids)
                .eq(Site::getDelFlag, 0));
        List<FavoriteSitePriceVO> result = sites.stream().map(site -> {
            FavoriteSitePriceVO vo = new FavoriteSitePriceVO();
            vo.setSiteId(site.getId());
            vo.setSiteName(site.getName());
            vo.setStatus(site.getStatus());
            vo.setCurrentPrice(getCurrentSitePrice(site));
            return vo;
        }).collect(Collectors.toList());
        return R.ok(result);
    }
    private BigDecimal getCurrentSitePrice(Site site) {
        if (site.getAccountingStrategyId() == null) {
            return null;
        }
        LocalTime now = LocalTime.now();
        List<TAccountingStrategyDetail> details = accountingStrategyDetailService.lambdaQuery()
                .eq(TAccountingStrategyDetail::getAccountingStrategyId, site.getAccountingStrategyId())
                .list();
        for (TAccountingStrategyDetail detail : details) {
            LocalTime start = parsePriceTime(detail.getStartTime(), false);
            LocalTime end = parsePriceTime(detail.getEndTime(), true);
            if (start != null && end != null && !now.isBefore(start) && !now.isAfter(end)) {
                return detail.getElectrovalence().add(detail.getServiceCharge())
                        .setScale(4, BigDecimal.ROUND_HALF_UP);
            }
        }
        return null;
    }
    private LocalTime parsePriceTime(String value, boolean endTime) {
        if (StringUtils.isEmpty(value)) {
            return null;
        }
        if (endTime && "00:00".equals(value)) {
            return LocalTime.of(23, 59, 59);
        }
        return LocalTime.parse(value.length() == 5 ? value + ":00" : value);
    }
    
    
    /**
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/dto/PartnerAppUserBindDTO.java
New file
@@ -0,0 +1,21 @@
package com.ruoyi.chargingPile.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.List;
@Data
@ApiModel("合作商绑定小程序用户参数")
public class PartnerAppUserBindDTO {
    @NotNull(message = "合作商ID不能为空")
    @ApiModelProperty(value = "合作商ID", required = true)
    private Integer partnerId;
    @ApiModelProperty("小程序用户ID列表")
    private List<Long> appUserIds = new ArrayList<>();
}
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/dto/PartnerAppUserVO.java
New file
@@ -0,0 +1,41 @@
package com.ruoyi.chargingPile.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@ApiModel("合作商可绑定的小程序用户")
public class PartnerAppUserVO {
    @ApiModelProperty("小程序用户ID")
    private Long appUserId;
    @ApiModelProperty("姓名")
    private String name;
    @ApiModelProperty("手机号")
    private String phone;
    @ApiModelProperty("备注")
    private String remark;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    @ApiModelProperty("创建时间")
    private LocalDateTime createTime;
    @ApiModelProperty("当前绑定合作商ID")
    private Integer partnerId;
    @ApiModelProperty("当前绑定合作商名称")
    private String partnerName;
    @ApiModelProperty("是否已绑定当前合作商")
    private Boolean selected;
    @ApiModelProperty("是否已绑定其他合作商")
    private Boolean disabled;
}
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/mapper/TPartnerAppUserMapper.java
New file
@@ -0,0 +1,9 @@
package com.ruoyi.chargingPile.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.chargingPile.api.model.TPartnerAppUser;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TPartnerAppUserMapper extends BaseMapper<TPartnerAppUser> {
}
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/service/TPartnerAppUserService.java
New file
@@ -0,0 +1,21 @@
package com.ruoyi.chargingPile.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.chargingPile.api.model.Partner;
import com.ruoyi.chargingPile.api.model.TPartnerAppUser;
import com.ruoyi.chargingPile.dto.PartnerAppUserBindDTO;
import com.ruoyi.chargingPile.dto.PartnerAppUserVO;
import com.ruoyi.common.core.web.page.PageInfo;
import java.util.List;
public interface TPartnerAppUserService extends IService<TPartnerAppUser> {
    PageInfo<PartnerAppUserVO> searchUsers(Integer partnerId, String phone, Integer pageNum, Integer pageSize);
    List<PartnerAppUserVO> getBoundUsers(Integer partnerId);
    void saveBindings(PartnerAppUserBindDTO bindDTO);
    Partner getPartnerByAppUserId(Long appUserId);
}
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/service/impl/TPartnerAppUserServiceImpl.java
New file
@@ -0,0 +1,190 @@
package com.ruoyi.chargingPile.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.api.feignClient.AppUserClient;
import com.ruoyi.account.api.model.TAppUser;
import com.ruoyi.chargingPile.api.model.Partner;
import com.ruoyi.chargingPile.api.model.TPartnerAppUser;
import com.ruoyi.chargingPile.dto.PartnerAppUserBindDTO;
import com.ruoyi.chargingPile.dto.PartnerAppUserVO;
import com.ruoyi.chargingPile.mapper.TPartnerAppUserMapper;
import com.ruoyi.chargingPile.service.IPartnerService;
import com.ruoyi.chargingPile.service.TPartnerAppUserService;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.web.page.PageInfo;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class TPartnerAppUserServiceImpl extends ServiceImpl<TPartnerAppUserMapper, TPartnerAppUser>
        implements TPartnerAppUserService {
    @Resource
    private AppUserClient appUserClient;
    @Resource
    private IPartnerService partnerService;
    @Override
    public PageInfo<PartnerAppUserVO> searchUsers(Integer partnerId, String phone, Integer pageNum, Integer pageSize) {
        assertPartnerExists(partnerId);
        int current = pageNum == null || pageNum < 1 ? 1 : pageNum;
        int size = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
        R<Page<TAppUser>> result = appUserClient.searchPartnerBindUsers(phone, current, size);
        Page<TAppUser> userPage = result == null ? null : result.getData();
        if (userPage == null) {
            throw new ServiceException("小程序用户服务暂不可用");
        }
        List<TAppUser> users = userPage.getRecords();
        Map<Long, TPartnerAppUser> relationMap = getRelationMap(users);
        Map<Integer, Partner> partnerMap = getPartnerMap(relationMap.values());
        List<PartnerAppUserVO> records = users.stream()
                .map(user -> toVO(user, relationMap.get(user.getId()), partnerMap, partnerId))
                .collect(Collectors.toList());
        PageInfo<PartnerAppUserVO> page = new PageInfo<>(userPage.getCurrent(), userPage.getSize());
        page.setTotal(userPage.getTotal());
        page.setRecords(records);
        return page;
    }
    @Override
    public List<PartnerAppUserVO> getBoundUsers(Integer partnerId) {
        assertPartnerExists(partnerId);
        List<TPartnerAppUser> relations = list(new LambdaQueryWrapper<TPartnerAppUser>()
                .eq(TPartnerAppUser::getPartnerId, partnerId)
                .orderByDesc(TPartnerAppUser::getCreateTime));
        if (relations.isEmpty()) {
            return Collections.emptyList();
        }
        List<Long> userIds = relations.stream()
                .map(TPartnerAppUser::getAppUserId)
                .collect(Collectors.toList());
        R<List<TAppUser>> userResult = appUserClient.getUserByIds(userIds);
        List<TAppUser> users = userResult == null ? null : userResult.getData();
        if (users == null) {
            throw new ServiceException("小程序用户服务暂不可用");
        }
        Map<Long, TAppUser> userMap = users.stream()
                .collect(Collectors.toMap(TAppUser::getId, Function.identity(), (left, right) -> left));
        Partner partner = partnerService.getById(partnerId);
        Map<Integer, Partner> partnerMap = Collections.singletonMap(partnerId, partner);
        return relations.stream()
                .map(relation -> {
                    TAppUser user = userMap.get(relation.getAppUserId());
                    return user == null ? null : toVO(user, relation, partnerMap, partnerId);
                })
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void saveBindings(PartnerAppUserBindDTO bindDTO) {
        Integer partnerId = bindDTO.getPartnerId();
        assertPartnerExists(partnerId);
        Set<Long> selectedIds = bindDTO.getAppUserIds() == null
                ? Collections.emptySet()
                : bindDTO.getAppUserIds().stream()
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        if (!selectedIds.isEmpty()) {
            long conflictCount = count(new LambdaQueryWrapper<TPartnerAppUser>()
                    .in(TPartnerAppUser::getAppUserId, selectedIds)
                    .ne(TPartnerAppUser::getPartnerId, partnerId));
            if (conflictCount > 0) {
                throw new ServiceException("所选手机号已绑定其他合作商,请刷新列表后重试");
            }
        }
        remove(new LambdaQueryWrapper<TPartnerAppUser>().eq(TPartnerAppUser::getPartnerId, partnerId));
        if (selectedIds.isEmpty()) {
            return;
        }
        List<TPartnerAppUser> relations = new ArrayList<>(selectedIds.size());
        for (Long appUserId : selectedIds) {
            TPartnerAppUser relation = new TPartnerAppUser();
            relation.setPartnerId(partnerId);
            relation.setAppUserId(appUserId);
            relations.add(relation);
        }
        saveBatch(relations);
    }
    @Override
    public Partner getPartnerByAppUserId(Long appUserId) {
        if (appUserId == null) {
            return null;
        }
        TPartnerAppUser relation = getOne(new LambdaQueryWrapper<TPartnerAppUser>()
                .eq(TPartnerAppUser::getAppUserId, appUserId)
                .last("limit 1"));
        return relation == null ? null : partnerService.getById(relation.getPartnerId());
    }
    private Map<Long, TPartnerAppUser> getRelationMap(List<TAppUser> users) {
        if (users == null || users.isEmpty()) {
            return Collections.emptyMap();
        }
        List<Long> userIds = users.stream().map(TAppUser::getId).collect(Collectors.toList());
        return list(new LambdaQueryWrapper<TPartnerAppUser>()
                .in(TPartnerAppUser::getAppUserId, userIds)).stream()
                .collect(Collectors.toMap(TPartnerAppUser::getAppUserId, Function.identity(), (left, right) -> left));
    }
    private Map<Integer, Partner> getPartnerMap(java.util.Collection<TPartnerAppUser> relations) {
        Set<Integer> partnerIds = relations.stream()
                .map(TPartnerAppUser::getPartnerId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (partnerIds.isEmpty()) {
            return Collections.emptyMap();
        }
        return partnerService.listByIds(partnerIds).stream()
                .collect(Collectors.toMap(Partner::getId, Function.identity(), (left, right) -> left));
    }
    private PartnerAppUserVO toVO(TAppUser user, TPartnerAppUser relation,
                                  Map<Integer, Partner> partnerMap, Integer currentPartnerId) {
        PartnerAppUserVO vo = new PartnerAppUserVO();
        vo.setAppUserId(user.getId());
        vo.setName(user.getName());
        vo.setPhone(user.getPhone());
        vo.setRemark(user.getRemark());
        vo.setCreateTime(user.getCreateTime());
        if (relation != null) {
            vo.setPartnerId(relation.getPartnerId());
            Partner partner = partnerMap.get(relation.getPartnerId());
            vo.setPartnerName(partner == null ? null : partner.getName());
        }
        boolean selected = relation != null && Objects.equals(relation.getPartnerId(), currentPartnerId);
        vo.setSelected(selected);
        vo.setDisabled(relation != null && !selected);
        return vo;
    }
    private void assertPartnerExists(Integer partnerId) {
        if (partnerId == null || partnerService.getById(partnerId) == null) {
            throw new ServiceException("合作商不存在");
        }
    }
}
ruoyi-service/ruoyi-chargingPile/src/main/resources/mapper/chargingPile/SiteMapper.xml
@@ -132,7 +132,7 @@
        select
        ts.id, ts.partner_id, ts.code, ts.`name`, ts.site_type, ts.business_category, ts.status, ts.construction_site, ts.img_url,
        ts.lon, ts.lat, ts.address, ts.country_code, ts.phone,ts.guide, ts.service_description, ts.vehicle_description,
        ts.parking_space, ts.rate_description, ts.space_charge_explain, ts.accounting_strategy_id,ts.del_flag,tcg.fastCount,
        ts.parking_space, ts.rate_description, ts.space_charge_explain, ts.accounting_strategy_id, ts.park_type as parkType, ts.park_fee as parkFee, ts.park_fee_free as parkFeeFree, ts.supportOrder as supportOrder, ts.equipment_owner_name as equipmentOwnerName, ts.rated_power as ratedPower, ts.del_flag,tcg.fastCount,
        tcg.slowCount,tcg.superCount,tasd.electrovalence,tasd.vipElectrovalence,tasd.serviceCharge,tasd.electrovalenceOriginal,tcg.superFreeCount,tcg.fastFreeCount,tcg.slowFreeCount,
        ROUND(
        6378.138 * 2 * ASIN(
@@ -204,7 +204,7 @@
                    ORDER BY tasd.electrovalence ASC
                </when>
                <when test="query.sortType == 3">
                    ORDER BY (tcg.fastCount + tcg.slowCount + tcg.superCount) DESC
                    ORDER BY (tcg.fastFreeCount + tcg.slowFreeCount + tcg.superFreeCount) DESC
                </when>
            </choose>
        </if>
@@ -267,7 +267,7 @@
        select
        ts.id, ts.partner_id, ts.code, ts.`name`, ts.site_type, ts.business_category, ts.status, ts.construction_site, ts.img_url,
        ts.lon, ts.lat, ts.address, ts.country_code, ts.phone,ts.guide, ts.service_description, ts.vehicle_description,
        ts.parking_space, ts.rate_description, ts.space_charge_explain, ts.accounting_strategy_id,ts.del_flag,tcg.fastCount,
        ts.parking_space, ts.rate_description, ts.space_charge_explain, ts.accounting_strategy_id, ts.park_type as parkType, ts.park_fee as parkFee, ts.park_fee_free as parkFeeFree, ts.supportOrder as supportOrder, ts.equipment_owner_name as equipmentOwnerName, ts.rated_power as ratedPower, ts.del_flag,tcg.fastCount,
        tcg.slowCount,tcg.superCount,tasd.electrovalence,tasd.vipElectrovalence,tcg.superFreeCount,tcg.fastFreeCount,tcg.slowFreeCount,
        ROUND(
        6378.138 * 2 * ASIN(
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/model/SecurityDetectionMessage.java
@@ -14,6 +14,7 @@
    private Integer secure_connection; // 车枪连接(0=未连接,1=连接)
    private Integer nsulation_detection; // 绝缘检测(0=安全,1=不安全)
    private Integer electronic_lock_lock; // 电磁锁状态(0=未锁,1=已锁)
    private String vin; // 当前车辆VIN码
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/AddChargingOrder.java
@@ -1,10 +1,13 @@
package com.ruoyi.order.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * @author zhibing.pu
@@ -27,5 +30,8 @@
    private Integer payMethod;
    @ApiModelProperty("回跳地址")
    private String returnUrl;
    @ApiModelProperty("预约充电时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime reservationTime;
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/MyChargingOrderList.java
@@ -15,6 +15,8 @@
public class MyChargingOrderList {
    @ApiModelProperty("订单id")
    private String id;
    @ApiModelProperty("站点id")
    private Integer siteId;
    @ApiModelProperty("订单状态(0=未知,1=等待中,2=启动中,3=充电中,4=停止中,5=已结束)")
    private Integer status;
    @ApiModelProperty("标题")
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/PreChargeCheck.java
@@ -23,6 +23,10 @@
    private Integer startupSuccess;
    @ApiModelProperty("失败原因(0:无,1:设备编号不匹配,2:枪已在充电,3:设备故障,4:设备离线,5:未插枪桩在收到启充命令后,检测到未插枪则发送 0x33 报文回复充电失败。若在 60 秒(以收到 0x34 时间开始计算)内检测到枪重新连接,则补送 0x33 成功报文;超时或者离线等其他异常,桩不启充、不补发 0x33 报文)")
    private Integer failureCause;
    @ApiModelProperty("设备检测到的车辆VIN")
    private String actualVin;
    @ApiModelProperty("启动命令是否已下发")
    private Boolean startCommandSent;
    @ApiModelProperty("添加时间")
    private LocalDateTime addTime;
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -268,6 +268,7 @@
        for (TChargingOrder tChargingOrder : list) {
            MyChargingOrderList myChargingOrderList = new MyChargingOrderList();
            myChargingOrderList.setId(tChargingOrder.getId().toString());
            myChargingOrderList.setSiteId(tChargingOrder.getSiteId());
            myChargingOrderList.setStatus(tChargingOrder.getStatus());
            Site site = siteClient.getSiteByIds(Arrays.asList(tChargingOrder.getSiteId())).getData().get(0);
            myChargingOrderList.setTitle(site.getName());
@@ -405,6 +406,7 @@
        for (TChargingOrder tChargingOrder : orderList) {
            MyChargingOrderList myChargingOrderList = new MyChargingOrderList();
            myChargingOrderList.setId(tChargingOrder.getId().toString());
            myChargingOrderList.setSiteId(tChargingOrder.getSiteId());
            myChargingOrderList.setStatus(tChargingOrder.getStatus());
            Site site = siteClient.getSiteByIds(Arrays.asList(tChargingOrder.getSiteId())).getData().get(0);
            myChargingOrderList.setTitle(site.getName());
@@ -652,6 +654,15 @@
        BigDecimal rechargeAmount = money(addChargingOrder.getPaymentAmount());
        TChargingGun tChargingGun = chargingGunClient.getChargingGunById(addChargingOrder.getId()).getData();
        TChargingPile chargingPile = chargingPileClient.getChargingPileById(tChargingGun.getChargingPileId()).getData();
        TAppUserCar selectedCar = null;
        if (addChargingOrder.getAppUserCarId() != null) {
            selectedCar = appUserCarClient.getCarById(addChargingOrder.getAppUserCarId().toString()).getData();
        }
        String selectedVin = selectedCar == null ? null : normalizeVin(selectedCar.getVin());
        if (Integer.valueOf(1).equals(chargingPile.getVinFlag())
                && (!StringUtils.hasLength(selectedVin) || !selectedVin.matches("[A-HJ-NPR-Z0-9]{17}"))) {
            return AjaxResult.error("该充电桩需要17位有效车辆VIN,请先完善所选车辆VIN");
        }
        AppUserWhitelistMatchVO whitelist = appUserWhitelistClient.matchWhitelist(userId, tChargingGun.getSiteId()).getData();
        boolean whitelistMatched = whitelist != null && Boolean.TRUE.equals(whitelist.getMatched());
        if (whitelistMatched) {
@@ -784,12 +795,11 @@
        chargingOrder.setOrderClassification(1);
        chargingOrder.setAppUserId(userId);
        chargingOrder.setAppUserCarId(addChargingOrder.getAppUserCarId());
        if (null != addChargingOrder.getAppUserCarId()) {
            TAppUserCar userCar = appUserCarClient.getCarById(addChargingOrder.getAppUserCarId().toString()).getData();
            if (null != userCar) {
                chargingOrder.setPlateNum(userCar.getLicensePlate());
            }
        if (selectedCar != null) {
            chargingOrder.setPlateNum(selectedCar.getLicensePlate());
            chargingOrder.setExpectedVin(selectedVin);
        }
        chargingOrder.setReservationTime(addChargingOrder.getReservationTime());
        chargingOrder.setSiteId(tChargingGun.getSiteId());
        chargingOrder.setChargingPileId(tChargingGun.getChargingPileId());
        chargingOrder.setChargingGunId(addChargingOrder.getId());
@@ -813,6 +823,10 @@
        chargingOrder.setTitle("【充电桩充电】" + chargingPile.getNumber() + "号桩/" + tChargingGun.getCode() + "号枪");
        
        Site site = siteClient.getSiteByIds(Arrays.asList(tChargingGun.getSiteId())).getData().get(0);
        AjaxResult reservationValidation = validateReservation(site, addChargingOrder.getReservationTime());
        if (reservationValidation != null) {
            return reservationValidation;
        }
        Integer accountingStrategyId = tChargingGun.getAccountingStrategyId();
        if (null == accountingStrategyId) {
            //查询站点上面的计费策略
@@ -948,7 +962,8 @@
        preChargeCheck.setInsulationTesting(false);
        preChargeCheck.setSecureConnectionDetection(false);
        preChargeCheck.setStartupSuccess(1);
        preChargeCheck.setAddTime(LocalDateTime.now());
        preChargeCheck.setStartCommandSent(false);
        preChargeCheck.setAddTime(chargingOrder.getReservationTime() != null ? chargingOrder.getReservationTime() : LocalDateTime.now());
        String key = "AQJC_" + chargingOrder.getChargingGunId();
        redisService.setCacheObject(key, preChargeCheck, 24L, TimeUnit.HOURS);
        
@@ -1048,42 +1063,140 @@
        this.updateById(chargingOrder);
        
        
        TChargingPile chargingPile = chargingPileClient.getChargingPileById(tChargingGun.getChargingPileId()).getData();
        //调用远程启动充电消息
        PlatformStartCharging platformStartCharging = new PlatformStartCharging();
        platformStartCharging.setTransaction_serial_number(chargingOrder.getCode());
        platformStartCharging.setCharging_pile_code(chargingPile.getCode());
        platformStartCharging.setCharging_gun_code(tChargingGun.getCode());
        //使用订单id作为逻辑卡号
        platformStartCharging.setCard_number(chargingOrder.getId().toString());
        platformStartCharging.setAccount_balance(account_balance);
        log.info(chargingOrder.getCode() + ":-------------------远程调起开始充电请求-------------------" + platformStartCharging.toString());
        sendMessageClient.platformStartCharging(platformStartCharging);
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.SECONDS, new LinkedBlockingDeque<>());
        threadPoolExecutor.execute(()->{
            //推送成都监管
            NotificationConnectorChargeStatusVo vo = new NotificationConnectorChargeStatusVo();
            vo.setOrderCode(chargingOrder.getCode());
            vo.setChargingGunId(chargingOrder.getChargingGunId());
            chengDuJianGuanClient.notificationConnectorChargeStatus(vo);
        });
        //异步线程检测远程启动的应答结果。如果失败,则需要全额退款
        Long id = chargingOrder.getId();
        //执行10分钟的定时任务检测
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
        scheduler.scheduleAtFixedRate(()->{
            if(timingDetection(id)){
                scheduler.shutdown();
            }
        }, 10, 1, TimeUnit.SECONDS);
        scheduleReservationStart(chargingOrder);
        return AjaxResult.success();
    }
    private AjaxResult validateReservation(Site site, LocalDateTime reservationTime) {
        if (reservationTime == null) {
            return null;
        }
        if (!Integer.valueOf(1).equals(site.getSupportOrder())) {
            return AjaxResult.error("该电站暂不支持预约充电");
        }
        if (reservationTime.isBefore(LocalDateTime.now().minusMinutes(1))) {
            return AjaxResult.error("预约时间不能早于当前时间");
        }
        if (!StringUtils.hasLength(site.getReservationStartTime())
                || !StringUtils.hasLength(site.getReservationEndTime())) {
            return AjaxResult.error("电站尚未配置预约时段");
        }
        LocalTime start = LocalTime.parse(site.getReservationStartTime(), DateTimeFormatter.ofPattern("HH:mm"));
        LocalTime end = LocalTime.parse(site.getReservationEndTime(), DateTimeFormatter.ofPattern("HH:mm"));
        if (!isTimeInWindow(LocalTime.now(), start, end)
                || !isTimeInWindow(reservationTime.toLocalTime(), start, end)) {
            return AjaxResult.error("当前时间或预约时间不在允许预约时段内");
        }
        return null;
    }
    private boolean isTimeInWindow(LocalTime value, LocalTime start, LocalTime end) {
        if (start.equals(end)) {
            return true;
        }
        if (start.isBefore(end)) {
            return !value.isBefore(start) && !value.isAfter(end);
        }
        return !value.isBefore(start) || !value.isAfter(end);
    }
    private boolean isSafetyCheckPassed(PreChargeCheck check) {
        return check != null
                && Boolean.TRUE.equals(check.getElectronicLockLock())
                && Boolean.TRUE.equals(check.getInsulationTesting())
                && Boolean.TRUE.equals(check.getSecureConnectionDetection());
    }
    private String normalizeVin(String vin) {
        return StringUtils.hasLength(vin) ? vin.replaceAll("\\s+", "").toUpperCase(Locale.ROOT) : null;
    }
    private boolean vinMatches(String expectedVin, String actualVin) {
        if (!StringUtils.hasLength(expectedVin) || !StringUtils.hasLength(actualVin)) {
            return false;
        }
        return expectedVin.equals(actualVin)
                || expectedVin.equals(new StringBuilder(actualVin).reverse().toString());
    }
    private void scheduleReservationStart(TChargingOrder chargingOrder) {
        if (chargingOrder.getReservationTime() == null || !chargingOrder.getReservationTime().isAfter(LocalDateTime.now())) {
            return;
        }
        long delay = Math.max(0L, Duration.between(LocalDateTime.now(), chargingOrder.getReservationTime()).toMillis());
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.schedule(() -> {
            try {
                TChargingOrder latest = this.getById(chargingOrder.getId());
                if (latest == null || latest.getStatus() != 2) {
                    return;
                }
                PreChargeCheck check = redisService.getCacheObject("AQJC_" + latest.getChargingGunId());
                if (isSafetyCheckPassed(check)) {
                    startChargingAfterSafety(latest, check);
                }
            } finally {
                scheduler.shutdown();
            }
        }, delay, TimeUnit.MILLISECONDS);
    }
    private void startChargingAfterSafety(TChargingOrder chargingOrder, PreChargeCheck check) {
        RLock lock = redissonClient.getLock("START_AFTER_SAFETY:" + chargingOrder.getId());
        try {
            if (!lock.tryLock(1, 10, TimeUnit.SECONDS)) {
                return;
            }
            TChargingOrder latest = this.getById(chargingOrder.getId());
            if (latest == null || latest.getStatus() != 2 || Boolean.TRUE.equals(check.getStartCommandSent())) {
                return;
            }
            if (latest.getReservationTime() != null && latest.getReservationTime().isAfter(LocalDateTime.now())) {
                return;
            }
            TChargingGun gun = chargingGunClient.getChargingGunById(latest.getChargingGunId()).getData();
            TChargingPile pile = chargingPileClient.getChargingPileById(gun.getChargingPileId()).getData();
            String actualVin = normalizeVin(check.getActualVin());
            String expectedVin = normalizeVin(latest.getExpectedVin());
            if (Integer.valueOf(1).equals(pile.getVinFlag())
                    && !vinMatches(expectedVin, actualVin)) {
                check.setStartupSuccess(3);
                check.setFailureCause(9);
                latest.setActualVin(actualVin);
                this.updateById(latest);
                redisService.setCacheObject("AQJC_" + latest.getChargingGunId(), check, 24L, TimeUnit.HOURS);
                refund(latest.getCode());
                return;
            }
            PlatformStartCharging command = new PlatformStartCharging();
            command.setTransaction_serial_number(latest.getCode());
            command.setCharging_pile_code(pile.getCode());
            command.setCharging_gun_code(gun.getCode());
            command.setCard_number(latest.getId().toString());
            command.setAccount_balance(latest.getChargeAmount());
            check.setStartCommandSent(true);
            latest.setActualVin(actualVin);
            this.updateById(latest);
            redisService.setCacheObject("AQJC_" + latest.getChargingGunId(), check, 24L, TimeUnit.HOURS);
            log.info(latest.getCode() + ":安全检测及VIN校验通过,下发启动充电命令:" + command);
            sendMessageClient.platformStartCharging(command);
            ScheduledExecutorService resultScheduler = Executors.newScheduledThreadPool(1);
            resultScheduler.scheduleAtFixedRate(() -> {
                if (timingDetection(latest.getId())) {
                    resultScheduler.shutdown();
                }
            }, 10, 1, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ServiceException("启动充电锁等待被中断");
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    /**
     * 定时检测mongodb数据库数据
     *
@@ -1245,10 +1358,23 @@
        TChargingGun chargingGun = chargingGunClient.getChargingGunByCode(code).getData();
        PreChargeCheck preChargeCheck1 = redisService.getCacheObject("AQJC_" + chargingGun.getId());
        if (null != preChargeCheck1) {
            preChargeCheck1.setElectronicLockLock(true);
            preChargeCheck1.setInsulationTesting(true);
            preChargeCheck1.setSecureConnectionDetection(securityDetection.getSecure_connection() == 1);
            preChargeCheck1.setElectronicLockLock(Integer.valueOf(1).equals(securityDetection.getElectronic_lock_lock()));
            preChargeCheck1.setInsulationTesting(Integer.valueOf(0).equals(securityDetection.getNsulation_detection()));
            preChargeCheck1.setSecureConnectionDetection(Integer.valueOf(1).equals(securityDetection.getSecure_connection()));
            preChargeCheck1.setActualVin(normalizeVin(securityDetection.getVin()));
            redisService.setCacheObject("AQJC_" + chargingGun.getId(), preChargeCheck1, 24L, TimeUnit.HOURS);
            if (isSafetyCheckPassed(preChargeCheck1)) {
                TChargingOrder pendingOrder = this.getOne(new LambdaQueryWrapper<TChargingOrder>()
                        .eq(TChargingOrder::getChargingGunId, chargingGun.getId())
                        .eq(TChargingOrder::getRechargePaymentStatus, 2)
                        .eq(TChargingOrder::getStatus, 2)
                        .eq(TChargingOrder::getDelFlag, 0)
                        .orderByDesc(TChargingOrder::getCreateTime)
                        .last("limit 1"));
                if (pendingOrder != null) {
                    startChargingAfterSafety(pendingOrder, preChargeCheck1);
                }
            }
        }
    }
    
@@ -5074,7 +5200,8 @@
        preChargeCheck.setInsulationTesting(false);
        preChargeCheck.setSecureConnectionDetection(false);
        preChargeCheck.setStartupSuccess(1);
        preChargeCheck.setAddTime(LocalDateTime.now());
        preChargeCheck.setStartCommandSent(false);
        preChargeCheck.setAddTime(chargingOrder.getReservationTime() != null ? chargingOrder.getReservationTime() : LocalDateTime.now());
        String key = "AQJC_" + chargingOrder.getChargingGunId();
        redisService.setCacheObject(key, preChargeCheck, 24L, TimeUnit.HOURS);
        
sql/20260711_scan_reservation_vin.sql
New file
@@ -0,0 +1,12 @@
-- 扫码充电 V2.0:预约时段与车辆 VIN 校验
ALTER TABLE charging_pile_service.t_site
    ADD COLUMN reservation_start_time varchar(5) NULL COMMENT '允许预约开始时间(HH:mm)' AFTER supportOrder,
    ADD COLUMN reservation_end_time varchar(5) NULL COMMENT '允许预约结束时间(HH:mm)' AFTER reservation_start_time;
ALTER TABLE charging_pile_account.t_app_user_car
    ADD COLUMN vin varchar(64) NULL COMMENT '车辆VIN码' AFTER license_plate;
ALTER TABLE charging_pile_order.t_charging_order
    ADD COLUMN reservation_time datetime NULL COMMENT '预约充电时间' AFTER plate_num,
    ADD COLUMN expected_vin varchar(64) NULL COMMENT '支付车辆VIN快照' AFTER reservation_time,
    ADD COLUMN actual_vin varchar(64) NULL COMMENT '启动时设备识别VIN' AFTER expected_vin;
sql/20260713_partner_app_user.sql
New file
@@ -0,0 +1,11 @@
USE `charging_pile_service`;
CREATE TABLE IF NOT EXISTS `t_partner_app_user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `partner_id` int NOT NULL COMMENT '合作商ID',
  `app_user_id` bigint NOT NULL COMMENT '小程序用户ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_app_user_id` (`app_user_id`),
  KEY `idx_partner_id` (`partner_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合作商绑定小程序用户';
sql/20260713_site_favorite.sql
New file
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `charging_pile_account`.`t_app_user_site_favorite` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `app_user_id` bigint NOT NULL COMMENT '小程序用户ID',
  `site_id` int NOT NULL COMMENT '站点ID',
  `favorite_price` decimal(10,4) NOT NULL DEFAULT 0.0000 COMMENT '收藏时电价',
  `notify_enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否允许降价提醒',
  `last_notified_price` decimal(10,4) DEFAULT NULL COMMENT '最近一次已提醒价格',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间',
  `del_flag` tinyint(1) NOT NULL DEFAULT 0 COMMENT '删除标记',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_site` (`app_user_id`,`site_id`),
  KEY `idx_notify` (`notify_enabled`,`del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序用户收藏站点';
-- Nacos 的 ruoyi-account 配置需要补充真实微信订阅消息模板 ID:
-- wx.applet.price-drop-template-id: 你的模板ID