diff --git a/.gitignore b/.gitignore index bb70f160..d411df11 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ node_modules ### 充值导入测试脚本(含 Excel 原始数据与本地配置) ### scripts/recharge-import/ +#文档# +*.md + ### AI 辅助工具 ### .video_agent .mimosa diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelAvailSubEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelAvailSubEntity.java new file mode 100644 index 00000000..7b8b5c5c --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelAvailSubEntity.java @@ -0,0 +1,88 @@ +package com.bsd.pjcloud.supermarket.entity; + +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.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TenantTable +@TableName("canteen_hotel_avail_sub") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店有房通知订阅") +public class SmHotelAvailSubEntity extends Model { + + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_NOTIFIED = "NOTIFIED"; + public static final String STATUS_CANCELLED = "CANCELLED"; + + /** 指定入住离店区间,整段可订才通知 */ + public static final String WATCH_RANGE = "RANGE"; + + /** 不限日期:近窗内任意一晚可订即通知 */ + public static final String WATCH_ANY = "ANY"; + + /** ANY 模式落库占位入住日(不参与业务判定) */ + public static final LocalDate ANY_SENTINEL_IN = LocalDate.of(1900, 1, 1); + + /** ANY 模式落库占位离店日(不参与业务判定) */ + public static final LocalDate ANY_SENTINEL_OUT = LocalDate.of(1900, 1, 2); + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + private Long userId; + + private Long hotelId; + + private String productCode; + + private LocalDate checkIn; + + private LocalDate checkOut; + + private Integer rooms; + + @Schema(description = "RANGE指定日期 / ANY不限日期") + private String watchMode; + + @Schema(description = "ACTIVE/NOTIFIED/CANCELLED") + private String status; + + @Schema(description = "推送幂等版本") + private Integer notifyVersion; + + private String hotelName; + + private String productName; + + private LocalDateTime notifiedTime; + + @TableField(fill = FieldFill.INSERT) + private String createBy; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + @TableField(fill = FieldFill.INSERT) + private String delFlag; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java index acdb8ed4..1a8c92f6 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java @@ -163,4 +163,13 @@ public class SmHotelOrderEntity extends Model { @TableField(exist = false) @Schema(description = "酒店数据来源") private String hotelDataSource; + + /** 列表展示:酒店归属部门(对应商户,结算用;非表字段,来自 canteen_hotel.dept_id) */ + @TableField(exist = false) + @Schema(description = "酒店归属部门ID") + private Long deptId; + + @TableField(exist = false) + @Schema(description = "酒店归属部门名称") + private String deptName; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelRoomEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelRoomEntity.java index c38f053d..b35b7a7f 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelRoomEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelRoomEntity.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +import java.math.BigDecimal; import java.time.LocalDateTime; @Data @@ -37,6 +38,15 @@ public class SmHotelRoomEntity extends Model { private Integer roomNumber; + @Schema(description = "楼层(如 3-5层)") + private String floorInfo; + + @Schema(description = "面积(㎡)") + private BigDecimal areaSqm; + + @Schema(description = "窗型:有窗/无窗/部分有窗/其他") + private String windowType; + private String rawJson; @TableField(fill = FieldFill.INSERT) diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderDetailVo.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderDetailVo.java index 39db5422..19858040 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderDetailVo.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderDetailVo.java @@ -84,6 +84,9 @@ public class SmHotelOrderDetailVo { private BigDecimal latitude; private Long deptId; + + @Schema(description = "酒店归属部门名称(商户)") + private String deptName; } @Data diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java index 3560e123..95ac97dc 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.supermarket.entity.SmHotelAvailSubEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity; @@ -21,6 +22,7 @@ import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper; import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper; import com.bsd.pjcloud.supermarket.mapper.SmHotelRoomMapper; import com.bsd.pjcloud.supermarket.mapper.SmHotelUserSearchPrefMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService; import com.bsd.pjcloud.supermarket.service.SmHotelMidService; import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; import com.bsd.pjcloud.supermarket.service.SmHotelService; @@ -41,7 +43,6 @@ import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; @@ -91,7 +92,7 @@ public class SmHotelAppController { private static final String CANDIDATE_CACHE_OVERFLOW = "OVERFLOW"; /** 列表页结果短缓存(同条件重复进入/下拉) */ - private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:v13:"; + private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:v14:"; private static final long PAGE_CACHE_SECONDS = 45L; @@ -121,6 +122,8 @@ public class SmHotelAppController { private final SmHotelProductMapper productMapper; + private final SmHotelAvailabilityNotifyService availabilityNotifyService; + private final StringRedisTemplate stringRedisTemplate; @Operation(summary = "列表筛选项(区域/星级/设施)") @@ -425,16 +428,32 @@ public class SmHotelAppController { } /** - * 按酒店名搜索: - * 1) 前缀匹配(idx_hotel_name + ORDER BY,快); - * 2) 无结果再含匹配——SQL 不做 ORDER BY,只 LIMIT,再内存排序(避免超时变空列表)。 - * 有城市时尽量落到 district_adcode 区间。 + * 按酒店名搜索(只打 canteen_hotel,几万级): + * 1) 先全国店名查出候选(前缀 → 含匹配),不带区划、不带日历,避免「有城市但酒店未落 adcode」直接变空; + * 2) 有城市时内存把本市排前面;本市一个都没有则仍返回全国店名命中(总比超时/无语强); + * 3) 日历最低价仍只查本页 id。 */ private Page pageByHotelNameKeyword(Page page, String keyword, String city, String district, Long districtAdcode, List starList) { long size = Math.max(1L, Math.min(page.getSize(), 50L)); long current = Math.max(1L, page.getCurrent()); - int fetch = (int) Math.min(KEYWORD_CONTAINS_FETCH, Math.max(size + 1, size * current + 1)); + int fetch = KEYWORD_CONTAINS_FETCH; + long tName = System.currentTimeMillis(); + List rows = hotelMapper.selectAppByNamePrefix(keyword, fetch); + String hit = "prefix"; + if (rows == null || rows.isEmpty()) { + rows = hotelMapper.selectAppByNameContains(keyword, fetch); + hit = "contains"; + } + if (rows == null) { + rows = List.of(); + } + Set starSet = starList == null || starList.isEmpty() ? Set.of() : new HashSet<>(starList); + if (!starSet.isEmpty()) { + rows = rows.stream() + .filter(h -> h.getStarLevel() != null && starSet.contains(h.getStarLevel())) + .toList(); + } Long adFrom = null; Long adTo = null; Long adcode = districtAdcode; @@ -449,20 +468,7 @@ public class SmHotelAppController { adFrom = range[0]; adTo = range[1]; } - Collection stars = starList == null || starList.isEmpty() ? null : starList; - List rows = hotelMapper.selectAppByNamePrefix(keyword, adFrom, adTo, stars, fetch); - if (rows == null || rows.isEmpty()) { - // 含匹配绝不带 ORDER BY;有城市时区间小,全国则最多捞 KEYWORD_CONTAINS_FETCH 条后内存排 - rows = hotelMapper.selectAppByNameContains(keyword, adFrom, adTo, stars, fetch); - } - if (rows == null) { - rows = List.of(); - } - List sorted = new ArrayList<>(rows); - sorted.sort(Comparator - .comparing((SmHotelEntity h) -> h.getStarLevel() == null ? 0 : h.getStarLevel()) - .reversed() - .thenComparing((SmHotelEntity h) -> h.getId() == null ? 0L : h.getId(), Comparator.reverseOrder())); + List sorted = rankKeywordHits(rows, adFrom, adTo); int from = (int) Math.min((current - 1) * size, sorted.size()); int to = (int) Math.min(from + size, sorted.size()); List slice = from >= sorted.size() ? List.of() : sorted.subList(from, to); @@ -470,6 +476,46 @@ public class SmHotelAppController { long total = (current - 1) * size + slice.size() + (hasMore ? 1 : 0); Page out = new Page<>(current, size, total); out.setRecords(new ArrayList<>(slice)); + log.info("|酒店App|店名搜索|kw={}|hit={}|cand={}|page={}|adcode={}|costMs={}|", keyword, hit, sorted.size(), + slice.size(), adcode, System.currentTimeMillis() - tName); + return out; + } + + /** 本市(adcode 命中)优先,再按星级、id;本市没有则保留全国店名命中 */ + private static List rankKeywordHits(List rows, Long adFrom, Long adTo) { + if (rows == null || rows.isEmpty()) { + return List.of(); + } + List inCity = new ArrayList<>(); + List other = new ArrayList<>(); + for (SmHotelEntity h : rows) { + if (h == null) { + continue; + } + if (adFrom != null && adTo != null && h.getDistrictAdcode() != null + && h.getDistrictAdcode() >= adFrom && h.getDistrictAdcode() < adTo) { + inCity.add(h); + } + else { + other.add(h); + } + } + Comparator byStar = Comparator + .comparing((SmHotelEntity h) -> h.getStarLevel() == null ? 0 : h.getStarLevel()) + .reversed() + .thenComparing((SmHotelEntity h) -> h.getId() == null ? 0L : h.getId(), Comparator.reverseOrder()); + inCity.sort(byStar); + other.sort(byStar); + List out = new ArrayList<>(inCity.size() + other.size()); + out.addAll(inCity); + // 本市一个都没有时,用全国店名结果,避免「库里有如家、界面无语」 + if (inCity.isEmpty()) { + out.addAll(other); + } + else { + // 本市有结果时仍可附带少量市外,便于翻页;首页优先本市 + out.addAll(other); + } return out; } @@ -837,6 +883,73 @@ public class SmHotelAppController { } } + @Operation(summary = "订阅有房通知") + @PostMapping("/availability-subscribe") + public R availabilitySubscribe(@RequestBody Map body) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null) { + return R.failed("请先登录"); + } + if (body == null) { + body = Map.of(); + } + Long hotelId = parseLong(body.get("hotelId")); + String productCode = str(body.get("productCode")); + LocalDate checkIn = parseDay(str(body.get("checkIn"))); + LocalDate checkOut = parseDay(str(body.get("checkOut"))); + Integer rooms = clampInt(body.get("rooms"), 1, 20, 1); + String watchMode = str(body.get("watchMode")); + return availabilityNotifyService.subscribe(user.getId(), hotelId, productCode, checkIn, checkOut, rooms, + watchMode); + } + + @Operation(summary = "取消有房通知订阅") + @PostMapping("/availability-unsubscribe") + public R availabilityUnsubscribe(@RequestBody Map body) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null) { + return R.failed("请先登录"); + } + if (body == null) { + body = Map.of(); + } + Long id = parseLong(body.get("id")); + Long hotelId = parseLong(body.get("hotelId")); + String productCode = str(body.get("productCode")); + LocalDate checkIn = parseDay(str(body.get("checkIn"))); + LocalDate checkOut = parseDay(str(body.get("checkOut"))); + Integer rooms = clampInt(body.get("rooms"), 1, 20, 1); + String watchMode = str(body.get("watchMode")); + return availabilityNotifyService.unsubscribe(user.getId(), id, hotelId, productCode, checkIn, checkOut, rooms, + watchMode); + } + + @Operation(summary = "我的有房通知订阅") + @GetMapping("/availability-subscriptions") + public R> availabilitySubscriptions(@ParameterObject Page page) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null) { + return R.failed("请先登录"); + } + return R.ok(availabilityNotifyService.pageMine(page, user.getId())); + } + + @Operation(summary = "有房通知订阅状态") + @GetMapping("/availability-subscribe/status") + public R> availabilitySubscribeStatus( + @RequestParam Long hotelId, + @RequestParam String productCode, + @RequestParam String checkIn, + @RequestParam String checkOut, + @RequestParam(required = false) Integer rooms) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null) { + return R.failed("请先登录"); + } + return R.ok(availabilityNotifyService.status(user.getId(), hotelId, productCode, + parseDay(checkIn), parseDay(checkOut), rooms)); + } + @Operation(summary = "酒店日历分页") @GetMapping("/detail/{id}/daily") public R> daily(@PathVariable Long id, @@ -1368,4 +1481,16 @@ public class SmHotelAppController { private static String str(Object o) { return o == null ? null : String.valueOf(o); } + + private static Long parseLong(Object raw) { + if (raw == null) { + return null; + } + try { + return Long.valueOf(String.valueOf(raw).trim()); + } + catch (Exception e) { + return null; + } + } } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java index 0826fc48..2da6808e 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java @@ -7,6 +7,7 @@ import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity; +import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService; import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService; import com.bsd.pjcloud.supermarket.service.SmHotelMidService; import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService; @@ -42,6 +43,8 @@ public class SmHotelMidController { private final SmHotelChannelDeptService channelDeptService; + private final SmHotelAvailabilityNotifyService availabilityNotifyService; + @Operation(summary = "查询全局售价调整配置(加价/折扣)") @GetMapping("/priceConfig") @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')") @@ -300,6 +303,14 @@ public class SmHotelMidController { return midService.batchLocalDaily(hotelId, req); } + @SysLog("手动扫描有房通知订阅") + @Operation(summary = "立即扫描该酒店 ACTIVE 有房订阅并尝试推送(联调/补推)") + @PostMapping("/local/{hotelId}/avail-notify/scan") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + public R> scanAvailNotify(@PathVariable Long hotelId) { + return R.ok(availabilityNotifyService.scanNow(hotelId)); + } + @SysLog("本地酒店删除日历") @DeleteMapping("/local/{hotelId}/daily/{dailyId}") @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelAvailSubMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelAvailSubMapper.java new file mode 100644 index 00000000..a0716f88 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelAvailSubMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelAvailSubEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SmHotelAvailSubMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelMapper.java index ac7a066c..cccfd0b9 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelMapper.java @@ -6,7 +6,6 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import java.util.Collection; import java.util.List; @Mapper @@ -15,58 +14,36 @@ public interface SmHotelMapper extends PjcloudBaseMapper { int insertBatch(@Param("list") List list); /** - * App 店名前缀搜索:走 idx_hotel_name,可带区划区间;带 ORDER BY 安全(前缀命中集小)。 + * 仅酒店表:店名前缀(走 idx_hotel_name)。几万级酒店 + LIMIT,秒级内。 */ @Select(""" - - """) - List selectAppByNamePrefix(@Param("keyword") String keyword, @Param("adFrom") Long adFrom, - @Param("adTo") Long adTo, @Param("starLevels") Collection starLevels, - @Param("limit") int limit); - - /** - * App 店名包含搜索:禁止 ORDER BY(含匹配 + 排序 = 全表 filesort 超时)。 - * 先 LIMIT 取候选,由调用方内存排序。有 adcode 时扫描范围极小。 - */ - @Select(""" - """) - List selectAppByNameContains(@Param("keyword") String keyword, @Param("adFrom") Long adFrom, - @Param("adTo") Long adTo, @Param("starLevels") Collection starLevels, - @Param("limit") int limit); + List selectAppByNamePrefix(@Param("keyword") String keyword, @Param("limit") int limit); + + /** + * 仅酒店表:店名包含。无 ORDER BY(避免 filesort);LIMIT 后内存排序。 + * 几万酒店全表扫含匹配通常远快于带日历/区划的复杂 SQL。 + */ + @Select(""" + SELECT id, hotel_name AS hotelName, hotel_code AS hotelCode, address, district, + district_adcode AS districtAdcode, biz_area AS bizArea, star_level AS starLevel, + score, facility_tags AS facilityTags, cover_image AS coverImage, phone, + sheet_name AS sheetName, upstream_status AS upstreamStatus, update_time AS updateTime + FROM canteen_hotel + WHERE del_flag = '0' + AND (upstream_status IS NULL OR upstream_status <> 'OFFLINE') + AND hotel_name LIKE CONCAT('%', #{keyword}, '%') + LIMIT #{limit} + """) + List selectAppByNameContains(@Param("keyword") String keyword, @Param("limit") int limit); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelAvailabilityNotifyService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelAvailabilityNotifyService.java new file mode 100644 index 00000000..a3c031a1 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelAvailabilityNotifyService.java @@ -0,0 +1,49 @@ +package com.bsd.pjcloud.supermarket.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.supermarket.entity.SmHotelAvailSubEntity; + +import java.time.LocalDate; +import java.util.Map; + +/** + * 酒店有房通知:用户订阅 CRUD + 日历变更后扫描推送。 + */ +public interface SmHotelAvailabilityNotifyService { + + /** + * 订阅有房通知。 + * + * @param watchMode RANGE=指定日期(须传 checkIn/checkOut);ANY=不限日期有房就通知 + */ + R subscribe(Long userId, Long hotelId, String productCode, + LocalDate checkIn, LocalDate checkOut, Integer rooms, String watchMode); + + /** + * 取消订阅(优先按 id;否则按同键)。 + */ + R unsubscribe(Long userId, Long id, Long hotelId, String productCode, + LocalDate checkIn, LocalDate checkOut, Integer rooms, String watchMode); + + /** + * 当前用户订阅分页。 + */ + Page pageMine(Page page, Long userId); + + /** + * 是否已订阅:匹配指定日期 RANGE,或同产品同间数的 ANY。 + */ + Map status(Long userId, Long hotelId, String productCode, + LocalDate checkIn, LocalDate checkOut, Integer rooms); + + /** + * 酒店日历/库存变化后防抖扫描 ACTIVE 订阅并推送。 + */ + void scheduleCheckAfterHotelChanged(Long hotelId); + + /** + * 立即扫描指定酒店的 ACTIVE 有房订阅(运维/联调手动触发)。 + */ + Map scanNow(Long hotelId); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelAvailabilityNotifyServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelAvailabilityNotifyServiceImpl.java new file mode 100644 index 00000000..fc630407 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelAvailabilityNotifyServiceImpl.java @@ -0,0 +1,609 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.supermarket.entity.SmHotelAvailSubEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelAvailSubMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService; +import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppMessageService; +import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelRoomAvailableEventDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 有房通知订阅与日历变更扫描推送。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SmHotelAvailabilityNotifyServiceImpl implements SmHotelAvailabilityNotifyService { + + private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE; + + private static final long DEBOUNCE_MS = 800L; + + /** ANY 模式扫描:今天起多少天内任意一晚可订即通知 */ + private static final int ANY_LOOKAHEAD_DAYS = 30; + + private final SmHotelAvailSubMapper availSubMapper; + + private final SmHotelMapper hotelMapper; + + private final SmHotelProductMapper productMapper; + + private final SmHotelDailyMapper dailyMapper; + + private final RemoterThirdAppMessageService remoteAppMessageService; + + private final ConcurrentHashMap> pendingByHotel = new ConcurrentHashMap<>(); + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "sm-hotel-avail-notify"); + t.setDaemon(true); + return t; + }); + + @Override + @Transactional(rollbackFor = Exception.class) + public R subscribe(Long userId, Long hotelId, String productCode, + LocalDate checkIn, LocalDate checkOut, Integer rooms, String watchMode) { + if (userId == null || hotelId == null || StrUtil.isBlank(productCode)) { + return R.failed("订阅参数不完整"); + } + String mode = normalizeWatchMode(watchMode); + boolean anyMode = SmHotelAvailSubEntity.WATCH_ANY.equals(mode); + LocalDate in; + LocalDate out; + if (anyMode) { + in = SmHotelAvailSubEntity.ANY_SENTINEL_IN; + out = SmHotelAvailSubEntity.ANY_SENTINEL_OUT; + } + else { + if (checkIn == null || checkOut == null) { + return R.failed("请选择入住和离店日期"); + } + if (!checkOut.isAfter(checkIn)) { + return R.failed("离店日须晚于入住日"); + } + in = checkIn; + out = checkOut; + } + int need = normalizeRooms(rooms); + SmHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null) { + return R.failed("酒店不存在"); + } + SmHotelProductEntity product = productMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, hotelId) + .eq(SmHotelProductEntity::getProductCode, productCode.trim()) + .last("LIMIT 1")); + if (product == null) { + return R.failed("产品不存在"); + } + if (isSatisfied(hotelId, product.getProductCode(), in, out, need, mode)) { + return R.failed(anyMode ? "近期已有可订房间,无需订阅" : "所选日期已有房,无需订阅"); + } + SmHotelAvailSubEntity exist = findByKey(userId, hotelId, product.getProductCode(), in, out, need, mode); + if (exist != null) { + if (SmHotelAvailSubEntity.STATUS_ACTIVE.equals(exist.getStatus())) { + return R.ok(exist); + } + int ver = exist.getNotifyVersion() == null ? 0 : exist.getNotifyVersion(); + exist.setStatus(SmHotelAvailSubEntity.STATUS_ACTIVE); + exist.setWatchMode(mode); + exist.setNotifyVersion(ver + 1); + exist.setNotifiedTime(null); + exist.setHotelName(hotel.getHotelName()); + exist.setProductName(product.getProductName()); + availSubMapper.updateById(exist); + log.info("|有房通知|重新激活|subId={}|mode={}|hotelId={}|product={}|", exist.getId(), mode, hotelId, + product.getProductCode()); + return R.ok(exist); + } + SmHotelAvailSubEntity row = new SmHotelAvailSubEntity(); + row.setUserId(userId); + row.setHotelId(hotelId); + row.setProductCode(product.getProductCode()); + row.setCheckIn(in); + row.setCheckOut(out); + row.setRooms(need); + row.setWatchMode(mode); + row.setStatus(SmHotelAvailSubEntity.STATUS_ACTIVE); + row.setNotifyVersion(0); + row.setHotelName(hotel.getHotelName()); + row.setProductName(product.getProductName()); + availSubMapper.insert(row); + log.info("|有房通知|新建订阅|subId={}|mode={}|userId={}|hotelId={}|product={}|{}~{}|rooms={}|", + row.getId(), mode, userId, hotelId, product.getProductCode(), in, out, need); + return R.ok(row); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R unsubscribe(Long userId, Long id, Long hotelId, String productCode, + LocalDate checkIn, LocalDate checkOut, Integer rooms, String watchMode) { + SmHotelAvailSubEntity row = null; + if (id != null) { + row = availSubMapper.selectById(id); + } + else if (userId != null && hotelId != null && StrUtil.isNotBlank(productCode)) { + String mode = normalizeWatchMode(watchMode); + LocalDate in; + LocalDate out; + if (SmHotelAvailSubEntity.WATCH_ANY.equals(mode)) { + in = SmHotelAvailSubEntity.ANY_SENTINEL_IN; + out = SmHotelAvailSubEntity.ANY_SENTINEL_OUT; + } + else { + in = checkIn; + out = checkOut; + } + if (in != null && out != null) { + row = findByKey(userId, hotelId, productCode.trim(), in, out, normalizeRooms(rooms), mode); + } + } + if (row == null || !Objects.equals(row.getUserId(), userId)) { + return R.failed("订阅不存在"); + } + if (SmHotelAvailSubEntity.STATUS_CANCELLED.equals(row.getStatus())) { + return R.ok(true); + } + row.setStatus(SmHotelAvailSubEntity.STATUS_CANCELLED); + availSubMapper.updateById(row); + return R.ok(true); + } + + @Override + public Page pageMine(Page page, Long userId) { + return availSubMapper.selectPage( + new Page<>(page.getCurrent(), page.getSize()), + Wrappers.lambdaQuery() + .eq(SmHotelAvailSubEntity::getUserId, userId) + .ne(SmHotelAvailSubEntity::getStatus, SmHotelAvailSubEntity.STATUS_CANCELLED) + .orderByDesc(SmHotelAvailSubEntity::getUpdateTime) + .orderByDesc(SmHotelAvailSubEntity::getId)); + } + + @Override + public Map status(Long userId, Long hotelId, String productCode, + LocalDate checkIn, LocalDate checkOut, Integer rooms) { + Map out = new HashMap<>(8); + out.put("subscribed", false); + out.put("subscriptionId", null); + out.put("watchMode", null); + if (userId == null || hotelId == null || StrUtil.isBlank(productCode)) { + return out; + } + int need = normalizeRooms(rooms); + String code = productCode.trim(); + // 优先:当前页筛选日期的 RANGE + if (checkIn != null && checkOut != null) { + SmHotelAvailSubEntity range = availSubMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelAvailSubEntity::getUserId, userId) + .eq(SmHotelAvailSubEntity::getHotelId, hotelId) + .eq(SmHotelAvailSubEntity::getProductCode, code) + .eq(SmHotelAvailSubEntity::getCheckIn, checkIn) + .eq(SmHotelAvailSubEntity::getCheckOut, checkOut) + .eq(SmHotelAvailSubEntity::getRooms, need) + .eq(SmHotelAvailSubEntity::getStatus, SmHotelAvailSubEntity.STATUS_ACTIVE) + .and(w -> w.eq(SmHotelAvailSubEntity::getWatchMode, SmHotelAvailSubEntity.WATCH_RANGE) + .or() + .isNull(SmHotelAvailSubEntity::getWatchMode)) + .last("LIMIT 1")); + if (range != null) { + out.put("subscribed", true); + out.put("subscriptionId", range.getId()); + out.put("watchMode", SmHotelAvailSubEntity.WATCH_RANGE); + return out; + } + } + // 其次:同产品同间数的 ANY(不限日期) + SmHotelAvailSubEntity any = availSubMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelAvailSubEntity::getUserId, userId) + .eq(SmHotelAvailSubEntity::getHotelId, hotelId) + .eq(SmHotelAvailSubEntity::getProductCode, code) + .eq(SmHotelAvailSubEntity::getRooms, need) + .eq(SmHotelAvailSubEntity::getWatchMode, SmHotelAvailSubEntity.WATCH_ANY) + .eq(SmHotelAvailSubEntity::getStatus, SmHotelAvailSubEntity.STATUS_ACTIVE) + .last("LIMIT 1")); + if (any != null) { + out.put("subscribed", true); + out.put("subscriptionId", any.getId()); + out.put("watchMode", SmHotelAvailSubEntity.WATCH_ANY); + } + return out; + } + + @Override + public void scheduleCheckAfterHotelChanged(Long hotelId) { + if (hotelId == null) { + return; + } + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = 1L; + } + Long tid = tenantId; + log.info("|有房通知|安排扫描|hotelId={}|tenantId={}|txActive={}|", hotelId, tid, + TransactionSynchronizationManager.isSynchronizationActive()); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + // 提交后短延迟再扫,确保日历行对查询可见;同酒店合并多次触发 + scheduleDebounced(hotelId, tid); + } + }); + } + else { + scheduleDebounced(hotelId, tid); + } + } + + @Override + public Map scanNow(Long hotelId) { + if (hotelId == null) { + Map empty = new LinkedHashMap<>(); + empty.put("hotelId", null); + empty.put("activeSubs", 0); + empty.put("notified", 0); + empty.put("skippedNotBookable", 0); + empty.put("feignFailed", 0); + return empty; + } + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = 1L; + } + return TenantBroker.applyAs(tenantId, t -> doScanAndNotify(hotelId)); + } + + private void scheduleDebounced(Long hotelId, Long tenantId) { + Long tid = tenantId == null ? 1L : tenantId; + ScheduledFuture prev = pendingByHotel.get(hotelId); + if (prev != null) { + prev.cancel(false); + } + ScheduledFuture future = scheduler.schedule(() -> { + pendingByHotel.remove(hotelId); + try { + log.info("|有房通知|开始异步扫描|hotelId={}|tenantId={}|", hotelId, tid); + TenantBroker.runAs(tid, t -> doScanAndNotify(hotelId)); + } + catch (Exception e) { + log.warn("|有房通知扫描失败|hotelId={}|{}", hotelId, e.getMessage(), e); + } + }, DEBOUNCE_MS, TimeUnit.MILLISECONDS); + pendingByHotel.put(hotelId, future); + } + + private Map doScanAndNotify(Long hotelId) { + Map summary = new LinkedHashMap<>(); + summary.put("hotelId", hotelId); + List subs = availSubMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelAvailSubEntity::getHotelId, hotelId) + .eq(SmHotelAvailSubEntity::getStatus, SmHotelAvailSubEntity.STATUS_ACTIVE) + .last("LIMIT 500")); + int active = CollUtil.isEmpty(subs) ? 0 : subs.size(); + summary.put("activeSubs", active); + int notified = 0; + int skipped = 0; + int feignFailed = 0; + if (CollUtil.isEmpty(subs)) { + log.info("|有房通知|扫描无ACTIVE订阅|hotelId={}|", hotelId); + summary.put("notified", 0); + summary.put("skippedNotBookable", 0); + summary.put("feignFailed", 0); + summary.put("hint", "该酒店没有 ACTIVE 订阅,请确认 App 已订阅成功且表 canteen_hotel_avail_sub.status=ACTIVE"); + return summary; + } + List> subBrief = new ArrayList<>(); + for (SmHotelAvailSubEntity s : subs) { + Map b = new LinkedHashMap<>(); + b.put("id", s.getId()); + b.put("productCode", s.getProductCode()); + b.put("watchMode", resolveWatchMode(s)); + b.put("rooms", s.getRooms()); + b.put("checkIn", s.getCheckIn() == null ? null : s.getCheckIn().toString()); + b.put("checkOut", s.getCheckOut() == null ? null : s.getCheckOut().toString()); + subBrief.add(b); + } + summary.put("subscriptions", subBrief); + log.info("|有房通知|开始扫描|hotelId={}|activeSubs={}|subs={}|", hotelId, active, subBrief); + for (SmHotelAvailSubEntity sub : subs) { + try { + String result = notifyIfReady(sub); + switch (result) { + case "NOTIFIED" -> notified++; + case "NOT_BOOKABLE" -> skipped++; + case "FEIGN_FAILED" -> feignFailed++; + default -> { + } + } + } + catch (Exception e) { + feignFailed++; + log.warn("|有房通知单条失败|subId={}|{}", sub.getId(), e.getMessage(), e); + } + } + summary.put("notified", notified); + summary.put("skippedNotBookable", skipped); + summary.put("feignFailed", feignFailed); + log.info("|有房通知|扫描结束|hotelId={}|notified={}|skippedNotBookable={}|feignFailed={}|", + hotelId, notified, skipped, feignFailed); + return summary; + } + + private String notifyIfReady(SmHotelAvailSubEntity sub) { + if (sub == null || sub.getId() == null) { + return "SKIP"; + } + // 兼容:库中 watch_mode 未写入时,用占位日期识别 ANY + String mode = resolveWatchMode(sub); + int need = normalizeRooms(sub.getRooms()); + log.info("|有房通知|检查订阅|subId={}|mode={}|dbWatchMode={}|product={}|{}~{}|rooms={}|", + sub.getId(), mode, sub.getWatchMode(), sub.getProductCode(), + sub.getCheckIn(), sub.getCheckOut(), need); + if (!isSatisfied(sub.getHotelId(), sub.getProductCode(), sub.getCheckIn(), sub.getCheckOut(), need, mode)) { + if (SmHotelAvailSubEntity.WATCH_ANY.equals(mode)) { + LocalDate from = LocalDate.now(); + LocalDate toEx = from.plusDays(ANY_LOOKAHEAD_DAYS); + log.info("|有房通知|ANY暂无可订跳过|subId={}|product={}|window={}~{}|rooms={}|", + sub.getId(), sub.getProductCode(), from, toEx, need); + } + else { + List missing = missingBookableNights(sub.getHotelId(), sub.getProductCode(), + sub.getCheckIn(), sub.getCheckOut(), need); + log.info("|有房通知|RANGE暂不可订跳过|subId={}|product={}|{}~{}|missingNights={}|", + sub.getId(), sub.getProductCode(), sub.getCheckIn(), sub.getCheckOut(), + missing.stream().map(LocalDate::toString).collect(Collectors.joining(","))); + } + return "NOT_BOOKABLE"; + } + int ver = sub.getNotifyVersion() == null ? 0 : sub.getNotifyVersion(); + String eventId = "HOTEL_ROOM:" + sub.getId() + ":" + ver; + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = sub.getTenantId(); + } + if (tenantId == null) { + tenantId = 1L; + } + String hotelName = StrUtil.blankToDefault(sub.getHotelName(), "酒店"); + String productName = StrUtil.blankToDefault(sub.getProductName(), sub.getProductCode()); + HotelRoomAvailableEventDto dto = new HotelRoomAvailableEventDto(); + dto.setEventId(eventId); + dto.setTenantId(tenantId); + dto.setRecipientUserId(sub.getUserId()); + dto.setHotelId(sub.getHotelId()); + dto.setProductCode(sub.getProductCode()); + dto.setSourceName(hotelName); + dto.setTitle("有房提醒"); + if (SmHotelAvailSubEntity.WATCH_ANY.equals(mode)) { + dto.setContent(hotelName + "「" + productName + "」近期已有空房,可前往查看预订"); + } + else { + dto.setContent(hotelName + "「" + productName + "」" + + sub.getCheckIn().format(DAY) + "至" + sub.getCheckOut().format(DAY) + + "已有空房,可前往预订"); + } + Map payload = new HashMap<>(8); + payload.put("hotelId", sub.getHotelId()); + payload.put("productCode", sub.getProductCode()); + payload.put("watchMode", mode); + payload.put("rooms", need); + payload.put("subscriptionId", sub.getId()); + if (SmHotelAvailSubEntity.WATCH_RANGE.equals(mode)) { + payload.put("checkIn", sub.getCheckIn().toString()); + payload.put("checkOut", sub.getCheckOut().toString()); + } + dto.setPayloadJson(JSONUtil.toJsonStr(payload)); + dto.setDeepLinkPath("/pages/hotel/detail"); + Map linkParams = new HashMap<>(8); + linkParams.put("id", String.valueOf(sub.getHotelId())); + linkParams.put("rooms", String.valueOf(need)); + if (SmHotelAvailSubEntity.WATCH_RANGE.equals(mode) && sub.getCheckIn() != null && sub.getCheckOut() != null) { + linkParams.put("checkIn", sub.getCheckIn().toString()); + linkParams.put("checkOut", sub.getCheckOut().toString()); + } + dto.setDeepLinkParamsJson(JSONUtil.toJsonStr(linkParams)); + dto.setOccurredTime(LocalDateTime.now()); + log.info("|有房通知|调用Feign入箱|subId={}|mode={}|eventId={}|userId={}|", + sub.getId(), mode, eventId, sub.getUserId()); + R resp; + try { + resp = remoteAppMessageService.receiveHotelRoomAvailableEvent(dto); + } + catch (Exception e) { + log.warn("|有房通知|Feign异常|subId={}|err={}|", sub.getId(), e.getMessage(), e); + return "FEIGN_FAILED"; + } + if (resp == null || !resp.isOk() || !Boolean.TRUE.equals(resp.getData())) { + log.warn("|有房通知Feign未成功|subId={}|code={}|msg={}|", + sub.getId(), resp == null ? null : resp.getCode(), resp == null ? "null" : resp.getMsg()); + return "FEIGN_FAILED"; + } + SmHotelAvailSubEntity upd = new SmHotelAvailSubEntity(); + upd.setId(sub.getId()); + upd.setStatus(SmHotelAvailSubEntity.STATUS_NOTIFIED); + upd.setNotifiedTime(LocalDateTime.now()); + int updated = availSubMapper.update(upd, Wrappers.lambdaUpdate() + .eq(SmHotelAvailSubEntity::getId, sub.getId()) + .eq(SmHotelAvailSubEntity::getStatus, SmHotelAvailSubEntity.STATUS_ACTIVE)); + log.info("|有房通知|入箱成功并标记NOTIFIED|subId={}|mode={}|updated={}|", sub.getId(), mode, updated); + return "NOTIFIED"; + } + + private boolean isSatisfied(Long hotelId, String productCode, LocalDate checkIn, LocalDate checkOut, + int rooms, String watchMode) { + if (SmHotelAvailSubEntity.WATCH_ANY.equals(normalizeWatchMode(watchMode))) { + return hasAnyBookableNight(hotelId, productCode, rooms); + } + // RANGE 但落了 ANY 占位日期时,按 ANY 处理,避免永远扫不到 + if (isAnySentinel(checkIn, checkOut)) { + return hasAnyBookableNight(hotelId, productCode, rooms); + } + return missingBookableNights(hotelId, productCode, checkIn, checkOut, rooms).isEmpty() + && checkIn != null && checkOut != null && checkOut.isAfter(checkIn); + } + + /** 近 ANY_LOOKAHEAD_DAYS 天内是否存在任意一晚可订 */ + private boolean hasAnyBookableNight(Long hotelId, String productCode, int rooms) { + if (hotelId == null || StrUtil.isBlank(productCode)) { + return false; + } + LocalDate from = LocalDate.now(); + LocalDate toEx = from.plusDays(ANY_LOOKAHEAD_DAYS); + List rows = dailyMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelDailyEntity::getHotelId, hotelId) + .eq(SmHotelDailyEntity::getProductCode, productCode) + .ge(SmHotelDailyEntity::getStayDate, from) + .lt(SmHotelDailyEntity::getStayDate, toEx)); + int rowCnt = rows == null ? 0 : rows.size(); + int openCnt = 0; + if (rows != null) { + for (SmHotelDailyEntity row : rows) { + if (row == null || row.getStayDate() == null) { + continue; + } + int status = row.getDailyStatus() == null ? 1 : row.getDailyStatus(); + int stock = row.getStock() == null ? 0 : row.getStock(); + int sold = row.getSold() == null ? 0 : row.getSold(); + if (status == 1 && stock - sold >= rooms) { + openCnt++; + log.info("|有房通知|ANY命中可订日|hotelId={}|product={}|stayDate={}|stock={}|sold={}|need={}|", + hotelId, productCode, row.getStayDate(), stock, sold, rooms); + return true; + } + } + } + log.info("|有房通知|ANY窗口无命中|hotelId={}|product={}|window={}~{}|dailyRows={}|openEnough={}|need={}|", + hotelId, productCode, from, toEx, rowCnt, openCnt, rooms); + return false; + } + + private static boolean isAnySentinel(LocalDate checkIn, LocalDate checkOut) { + return SmHotelAvailSubEntity.ANY_SENTINEL_IN.equals(checkIn) + && SmHotelAvailSubEntity.ANY_SENTINEL_OUT.equals(checkOut); + } + + private static String resolveWatchMode(SmHotelAvailSubEntity sub) { + if (sub == null) { + return SmHotelAvailSubEntity.WATCH_RANGE; + } + if (isAnySentinel(sub.getCheckIn(), sub.getCheckOut())) { + return SmHotelAvailSubEntity.WATCH_ANY; + } + return normalizeWatchMode(sub.getWatchMode()); + } + + private List missingBookableNights(Long hotelId, String productCode, LocalDate checkIn, + LocalDate checkOut, int rooms) { + List missing = new ArrayList<>(); + if (hotelId == null || StrUtil.isBlank(productCode) || checkIn == null || checkOut == null + || !checkOut.isAfter(checkIn)) { + if (checkIn != null) { + missing.add(checkIn); + } + return missing; + } + List nights = new ArrayList<>(); + for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) { + nights.add(d); + } + List rows = dailyMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelDailyEntity::getHotelId, hotelId) + .eq(SmHotelDailyEntity::getProductCode, productCode) + .ge(SmHotelDailyEntity::getStayDate, checkIn) + .lt(SmHotelDailyEntity::getStayDate, checkOut)); + Set ok = new HashSet<>(); + if (rows != null) { + for (SmHotelDailyEntity row : rows) { + if (row == null || row.getStayDate() == null) { + continue; + } + int status = row.getDailyStatus() == null ? 1 : row.getDailyStatus(); + int stock = row.getStock() == null ? 0 : row.getStock(); + int sold = row.getSold() == null ? 0 : row.getSold(); + if (status == 1 && stock - sold >= rooms) { + ok.add(row.getStayDate()); + } + } + } + for (LocalDate night : nights) { + if (!ok.contains(night)) { + missing.add(night); + } + } + return missing; + } + + private SmHotelAvailSubEntity findByKey(Long userId, Long hotelId, String productCode, + LocalDate checkIn, LocalDate checkOut, int rooms, String watchMode) { + var q = Wrappers.lambdaQuery() + .eq(SmHotelAvailSubEntity::getUserId, userId) + .eq(SmHotelAvailSubEntity::getHotelId, hotelId) + .eq(SmHotelAvailSubEntity::getProductCode, productCode) + .eq(SmHotelAvailSubEntity::getCheckIn, checkIn) + .eq(SmHotelAvailSubEntity::getCheckOut, checkOut) + .eq(SmHotelAvailSubEntity::getRooms, rooms) + .last("LIMIT 1"); + if (SmHotelAvailSubEntity.WATCH_ANY.equals(watchMode)) { + q.eq(SmHotelAvailSubEntity::getWatchMode, SmHotelAvailSubEntity.WATCH_ANY); + } + else { + q.and(w -> w.eq(SmHotelAvailSubEntity::getWatchMode, SmHotelAvailSubEntity.WATCH_RANGE) + .or() + .isNull(SmHotelAvailSubEntity::getWatchMode)); + } + return availSubMapper.selectOne(q); + } + + private static String normalizeWatchMode(String watchMode) { + if (SmHotelAvailSubEntity.WATCH_ANY.equalsIgnoreCase(StrUtil.trim(watchMode))) { + return SmHotelAvailSubEntity.WATCH_ANY; + } + return SmHotelAvailSubEntity.WATCH_RANGE; + } + + private static int normalizeRooms(Integer rooms) { + if (rooms == null || rooms < 1) { + return 1; + } + return Math.min(rooms, 20); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java index 4a50415a..c74290d7 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java @@ -22,6 +22,7 @@ import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamPushLogConte import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelApi; import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient; import com.bsd.pjcloud.supermarket.mapper.*; +import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService; import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService; import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService; import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAutoPushService; @@ -91,6 +92,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { private final SmHotelOpenResourceNotifyService openResourceNotifyService; + /** 有房通知扫描(日历变更后防抖)。 */ + private final SmHotelAvailabilityNotifyService availabilityNotifyService; + private final SmHotelMapper hotelMapper; private final SmHotelRoomMapper roomMapper; @@ -106,7 +110,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { SmHotelPriceConfigService priceConfigService, SmHotelChannelConfigService channelConfigService, SmHotelChannelDeptService channelDeptService, SmHotelDownstreamPushLogService downstreamPushLogService, @Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService, - @Lazy SmHotelOpenResourceNotifyService openResourceNotifyService, SmHotelMapper hotelMapper, + @Lazy SmHotelOpenResourceNotifyService openResourceNotifyService, + @Lazy SmHotelAvailabilityNotifyService availabilityNotifyService, SmHotelMapper hotelMapper, SmHotelRoomMapper roomMapper, SmHotelProductMapper productMapper, SmHotelDailyMapper dailyMapper, StringRedisTemplate stringRedisTemplate) { this.upstreamClient = upstreamClient; @@ -119,6 +124,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { this.downstreamPushLogService = downstreamPushLogService; this.downstreamAutoPushService = downstreamAutoPushService; this.openResourceNotifyService = openResourceNotifyService; + this.availabilityNotifyService = availabilityNotifyService; this.hotelMapper = hotelMapper; this.roomMapper = roomMapper; this.productMapper = productMapper; @@ -225,6 +231,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { log.info("|酒店拉取上游|结束|hotelCode={}|room={}|product={}|daily={}|tips={}|", hotelCode, roomCount, productCount, dailyCount, upstreamTips); downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + availabilityNotifyService.scheduleCheckAfterHotelChanged(hotelId); return R.ok(result); } @@ -353,6 +360,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { result.put("endExclusive", endExclusive.toString()); // 回调区间拉价 / 手动滚价后,防抖推下游 downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + availabilityNotifyService.scheduleCheckAfterHotelChanged(hotelId); return R.ok(result); } @@ -838,6 +846,37 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { return 20; } + /** 窗型文案 → 环启 roomWindow:0无窗/1有窗/2部分有窗/99其他 */ + private static int mapRoomWindowCode(String windowType) { + if (StrUtil.isBlank(windowType)) { + return 99; + } + String t = windowType.trim(); + if (t.contains("部分")) { + return 2; + } + if (t.contains("无窗") || "0".equals(t)) { + return 0; + } + if (t.contains("有窗") || "1".equals(t)) { + return 1; + } + return 99; + } + + /** 上游 roomWindow 码 → 本地窗型文案 */ + private static String mapWindowTypeLabel(Integer roomWindow) { + if (roomWindow == null) { + return null; + } + return switch (roomWindow) { + case 0 -> "无窗"; + case 1 -> "有窗"; + case 2 -> "部分有窗"; + default -> "其他"; + }; + } + /** 按产品推整窗关房区间;返回推送的 list 段数 */ private int pushCloseDailyRanges(String partnerCode, SmHotelEntity hotel, List products, BigDecimal defaultPrice) { @@ -1041,8 +1080,11 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { item.put("extApartmentId", room.getRoomTypeCode()); item.put("apartmentName", StrUtil.blankToDefault(room.getRoomTypeName(), room.getRoomTypeCode())); item.put("bedType", room.getBedType()); - // 环启 roomWindow:0无窗/1有窗/2部分有窗/99其他;本地无字段时按 99 - item.put("roomWindow", 99); + item.put("floorInfo", room.getFloorInfo()); + item.put("areaSqm", room.getAreaSqm()); + item.put("windowType", room.getWindowType()); + // 环启 roomWindow:0无窗/1有窗/2部分有窗/99其他 + item.put("roomWindow", mapRoomWindowCode(room.getWindowType())); int capacity = room.getCapacity() == null ? 2 : room.getCapacity(); item.put("capacity", capacity); item.put("apartmentNumber", roomQtyMap.getOrDefault(room.getRoomTypeCode(), 1)); @@ -1680,6 +1722,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { SmHotelDailyEntity saved = dailyMapper.selectById(daily.getId()); fillDailyRemain(saved); downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + availabilityNotifyService.scheduleCheckAfterHotelChanged(hotelId); return R.ok(saved); } @@ -1694,6 +1737,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { boolean ok = dailyMapper.deleteById(dailyId) > 0; if (ok) { downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + availabilityNotifyService.scheduleCheckAfterHotelChanged(hotelId); } return R.ok(ok); } @@ -1819,6 +1863,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { ? String.format("已补齐 %d 天行,跳过已有 %d", toInsert.size(), skippedExist) : String.format("已调整 %d 行,新补齐 %d,配额不足跳过 %d", toUpdate.size(), toInsert.size(), skippedStock); downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + availabilityNotifyService.scheduleCheckAfterHotelChanged(hotelId); return R.ok(data, msg); } @@ -1959,6 +2004,21 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { room.setBedType(item.getString("bedType")); room.setCapacity(item.getInteger("maxCheckInPeoNum")); room.setRoomNumber(item.getInteger("roomNum")); + String floor = StrUtil.blankToDefault(item.getString("floorInfo"), item.getString("floor")); + room.setFloorInfo(StrUtil.blankToDefault(floor, null)); + BigDecimal area = item.getBigDecimal("areaSqm"); + if (area == null) { + area = item.getBigDecimal("area"); + } + if (area == null) { + area = item.getBigDecimal("roomArea"); + } + room.setAreaSqm(area); + String windowType = item.getString("windowType"); + if (StrUtil.isBlank(windowType)) { + windowType = mapWindowTypeLabel(item.getInteger("roomWindow")); + } + room.setWindowType(StrUtil.blankToDefault(windowType, null)); room.setRawJson(item.toJSONString()); room.setUpdateTime(now); if (exist == null) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java index ecb2acc9..3be73e52 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java @@ -19,6 +19,7 @@ import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrder import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderResult; import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouBookStatus; import com.bsd.pjcloud.supermarket.mapper.*; +import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService; import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService; import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService; import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; @@ -26,6 +27,8 @@ import com.bsd.pjcloud.supermarket.service.SmHotelOrderUpstreamLogService; import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService; import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo; import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.tenant.TenantBroker; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; @@ -108,6 +111,10 @@ public class SmHotelOrderServiceImpl extends ServiceImpl pageOrders(Page page, SmHotelOrderEntity query) { Page result = baseMapper.selectPage(page, Wrappers.lambdaQuery() @@ -343,6 +350,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl records) { + List deptIds = records.stream() + .map(SmHotelOrderEntity::getDeptId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (deptIds.isEmpty()) { + return; + } + Map nameById = new HashMap<>(); + try { + R> r = remoteDeptService.getDeptByDeptIds(deptIds); + if (r != null && r.isOk() && r.getData() != null) { + r.getData().forEach((id, d) -> { + if (id != null && d != null && StrUtil.isNotBlank(d.getName())) { + nameById.put(id, d.getName()); + } + }); + } + } + catch (Exception e) { + log.warn("|酒店订单|补全部门名失败|{}", e.getMessage()); + } + for (SmHotelOrderEntity o : records) { + if (o.getDeptId() == null) { + continue; + } + o.setDeptName(nameById.getOrDefault(o.getDeptId(), String.valueOf(o.getDeptId()))); + } } private static void indexOpenAppNames(Map partnerNameMap, @@ -522,10 +563,31 @@ public class SmHotelOrderServiceImpl extends ServiceImpl> r = remoteDeptService.getDeptByDeptIds(List.of(hotel.getDeptId())); + if (r != null && r.isOk() && r.getData() != null) { + SysDept d = r.getData().get(hotel.getDeptId()); + if (d != null && StrUtil.isNotBlank(d.getName())) { + hb.setDeptName(d.getName()); + } + } + } + catch (Exception e) { + log.warn("|酒店订单详情|补全部门名失败|deptId={}|{}", hotel.getDeptId(), e.getMessage()); + } + if (StrUtil.isBlank(hb.getDeptName())) { + hb.setDeptName(String.valueOf(hotel.getDeptId())); + } + } vo.setHotel(hb); if (StrUtil.isBlank(order.getHotelName())) { order.setHotelName(hotel.getHotelName()); } + if (order.getDeptId() == null) { + order.setDeptId(hotel.getDeptId()); + order.setDeptName(hb.getDeptName()); + } } SmHotelProductEntity product = null; @@ -1869,6 +1931,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl INSERT INTO canteen_hotel_room ( id, tenant_id, hotel_id, group_code, hotel_code, room_type_code, room_type_name, - bed_type, capacity, room_number, raw_json, + bed_type, capacity, room_number, floor_info, area_sqm, window_type, raw_json, create_by, create_time, update_by, update_time, del_flag ) VALUES ( #{e.id}, #{e.tenantId}, #{e.hotelId}, #{e.groupCode}, #{e.hotelCode}, #{e.roomTypeCode}, #{e.roomTypeName}, #{e.bedType}, #{e.capacity}, #{e.roomNumber}, + #{e.floorInfo}, #{e.areaSqm}, #{e.windowType}, #{e.rawJson}, #{e.createBy}, #{e.createTime}, #{e.updateBy}, #{e.updateTime}, #{e.delFlag} ) diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppMessageService.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppMessageService.java index 5bc80465..80d64b74 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppMessageService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppMessageService.java @@ -3,6 +3,7 @@ package com.bsd.pjcloud.thirdparty.api.feign; import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.feign.annotation.NoToken; +import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelRoomAvailableEventDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageEventDto; import jakarta.validation.Valid; @@ -37,4 +38,14 @@ public interface RemoterThirdAppMessageService { @NoToken @PostMapping("/app-message/inner/meal-verified-event") R receiveMealVerifiedEvent(@Valid @RequestBody MealVerifiedEventDto dto); + + /** + * 幂等接收酒店有房通知(旁路,不进商户策略矩阵)。 + * + * @param dto 超市酒店订阅触发的事件快照 + * @return true表示消息中心已幂等接收 + */ + @NoToken + @PostMapping("/app-message/inner/hotel-room-available-event") + R receiveHotelRoomAvailableEvent(@Valid @RequestBody HotelRoomAvailableEventDto dto); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/HotelRoomAvailableEventDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/HotelRoomAvailableEventDto.java new file mode 100644 index 00000000..540e7e4a --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/HotelRoomAvailableEventDto.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.thirdparty.dto.appmessage; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 超市酒店服务提交的有房通知事件;旁路入箱,不走商户策略与关系订阅。 + */ +@Data +public class HotelRoomAvailableEventDto implements Serializable { + + /** 跨服务幂等事件ID,建议 HOTEL_ROOM:{subId}:{notifyVersion}。 */ + @NotBlank(message = "消息事件ID不能为空") + private String eventId; + + /** 事件所属租户ID。 */ + @NotNull(message = "租户ID不能为空") + private Long tenantId; + + /** 订阅用户ID(本人接收)。 */ + @NotNull(message = "接收用户ID不能为空") + private Long recipientUserId; + + /** 酒店ID,写入 payload / 深链。 */ + @NotNull(message = "酒店ID不能为空") + private Long hotelId; + + /** 产品编码快照。 */ + private String productCode; + + /** 展示标题。 */ + @NotBlank(message = "标题不能为空") + private String title; + + /** 展示正文。 */ + @NotBlank(message = "正文不能为空") + private String content; + + /** 来源名称快照(酒店名),写入 sourceMchName 供列表展示。 */ + private String sourceName; + + /** 详情 payload JSON;可为空。 */ + private String payloadJson; + + /** App 深链路径,默认消息详情;可指向酒店详情。 */ + private String deepLinkPath; + + /** App 深链参数 JSON。 */ + private String deepLinkParamsJson; + + /** 业务发生时间。 */ + @NotNull(message = "发生时间不能为空") + private LocalDateTime occurredTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java index 57fef5c6..55607631 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java @@ -37,12 +37,23 @@ public class AppMessageConstants { /** PC人工通知来源服务,区别于自动钱包和用餐事件。 */ public static final String SOURCE_THIRD = "THIRD"; + /** 超市酒店业务来源服务,用于有房通知等旁路事件。 */ + public static final String SOURCE_SUPERMARKET = "SUPERMARKET"; + /** 手动推送的稳定事件编码,只用于消息和投递,不加入自动事件策略或关系订阅矩阵。 */ public static final String EVENT_MANUAL_PUSH = "MANUAL_PUSH"; + /** + * 酒店有房通知事件编码,旁路入箱,不加入 AppMessageEventEnum / 商户策略矩阵 / 关系订阅。 + */ + public static final String EVENT_HOTEL_ROOM_AVAILABLE = "HOTEL_ROOM_AVAILABLE"; + /** 手动通知在App中的展示分类,跨端协议值为system。 */ public static final String CATEGORY_SYSTEM = "system"; + /** 酒店类通知在App中的展示分类。 */ + public static final String CATEGORY_HOTEL = "hotel"; + /** 新建后等待供应商投递的状态。 */ public static final String DELIVERY_PENDING = "PENDING"; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java index e8855a75..7a0d299d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java @@ -9,10 +9,12 @@ import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionBatchSaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionSaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserPushSettingSaveDto; +import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelRoomAvailableEventDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageEventDto; import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageService; import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdHotelRoomNotifyService; import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageEventOptionVo; import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageVo; import com.bsd.pjcloud.thirdparty.vo.appmessage.AppRelationSubscriptionVo; @@ -50,6 +52,9 @@ public class ThirdAppMessageController { /** 用户Push偏好和关系订阅服务。 */ private final ThirdAppMessageSettingService settingService; + /** 酒店有房通知旁路入箱。 */ + private final ThirdHotelRoomNotifyService hotelRoomNotifyService; + /** * 内部幂等接收Wallet Outbox聚合事件。 * @@ -74,6 +79,18 @@ public class ThirdAppMessageController { return R.ok(TenantBroker.applyAs(dto.getTenantId(), tenantId -> messageService.receiveMealVerifiedEvent(dto))); } + /** + * 内部幂等接收酒店有房通知(不进入商户策略/关系订阅矩阵)。 + * + * @param dto 超市酒店事件快照 + * @return true表示消息中心已完整接收 + */ + @Inner + @PostMapping("/inner/hotel-room-available-event") + public R receiveHotelRoomAvailableEvent(@Valid @RequestBody HotelRoomAvailableEventDto dto) { + return R.ok(TenantBroker.applyAs(dto.getTenantId(), tenantId -> hotelRoomNotifyService.receive(dto))); + } + /** * 分页查询当前用户全部或未读消息。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdHotelRoomNotifyService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdHotelRoomNotifyService.java new file mode 100644 index 00000000..6666fe40 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdHotelRoomNotifyService.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.thirdparty.service.appmessage; + +import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelRoomAvailableEventDto; + +/** + * 酒店有房通知旁路入箱:只写本人消息并按用户 Push 总开关创建投递,不读商户策略、不扇出关系消息。 + */ +public interface ThirdHotelRoomNotifyService { + + /** + * 幂等接收有房通知事件。 + * + * @param dto 超市模块提交的事件快照 + * @return true 表示已入箱或此前已幂等完成 + */ + boolean receive(HotelRoomAvailableEventDto dto); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java index a55c2a2a..e86821e3 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java @@ -625,8 +625,15 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService { AppMessageVo vo = new AppMessageVo(); BeanUtils.copyProperties(entity, vo); AppMessageEventEnum event = AppMessageEventEnum.fromCode(entity.getEventCode()); - vo.setCategory(AppMessageConstants.EVENT_MANUAL_PUSH.equals(entity.getEventCode()) - ? AppMessageConstants.CATEGORY_SYSTEM : event == null ? null : event.getCategory()); + if (AppMessageConstants.EVENT_MANUAL_PUSH.equals(entity.getEventCode())) { + vo.setCategory(AppMessageConstants.CATEGORY_SYSTEM); + } + else if (AppMessageConstants.EVENT_HOTEL_ROOM_AVAILABLE.equals(entity.getEventCode())) { + vo.setCategory(AppMessageConstants.CATEGORY_HOTEL); + } + else { + vo.setCategory(event == null ? null : event.getCategory()); + } vo.setRead(AppMessageConstants.READ.equals(entity.getReadStatus())); vo.setTitle(resolveLocalizedTitle(entity, event)); vo.setContent(resolveLocalizedContent(entity, event)); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdHotelRoomNotifyServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdHotelRoomNotifyServiceImpl.java new file mode 100644 index 00000000..f84a7e71 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdHotelRoomNotifyServiceImpl.java @@ -0,0 +1,92 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appmessage; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants; +import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelRoomAvailableEventDto; +import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppMessageEntity; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppMessageMapper; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdHotelRoomNotifyService; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 酒店有房通知:仿手动推送旁路,避免进入商户策略矩阵。 + */ +@Service +@RequiredArgsConstructor +public class ThirdHotelRoomNotifyServiceImpl implements ThirdHotelRoomNotifyService { + + private final ThirdAppMessageMapper messageMapper; + + private final ThirdAppMessageSettingService settingService; + + private final ThirdPushDeliveryService deliveryService; + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean receive(HotelRoomAvailableEventDto dto) { + if (dto == null || StrUtil.isBlank(dto.getEventId()) || dto.getRecipientUserId() == null) { + throw new IllegalArgumentException("有房通知参数不完整"); + } + if (ownerMessageExists(dto.getEventId(), dto.getRecipientUserId())) { + return true; + } + String title = trimToMax(dto.getTitle(), AppMessageConstants.MESSAGE_TITLE_MAX_LENGTH); + String content = trimToMax(dto.getContent(), AppMessageConstants.MESSAGE_CONTENT_MAX_LENGTH); + ThirdAppMessageEntity message = new ThirdAppMessageEntity(); + message.setTenantId(dto.getTenantId()); + message.setEventId(dto.getEventId()); + message.setEventCode(AppMessageConstants.EVENT_HOTEL_ROOM_AVAILABLE); + message.setRecipientUserId(dto.getRecipientUserId()); + message.setSubjectUserId(dto.getRecipientUserId()); + message.setRecipientType(AppMessageConstants.RECIPIENT_OWNER); + message.setSourceService(AppMessageConstants.SOURCE_SUPERMARKET); + message.setSourceBizType(AppMessageConstants.EVENT_HOTEL_ROOM_AVAILABLE); + message.setSourceBizId(String.valueOf(dto.getHotelId())); + message.setSourceMchId(null); + message.setSourceMchName(StrUtil.blankToDefault(dto.getSourceName(), null)); + message.setTitle(title); + message.setContent(content); + message.setPayloadJson(dto.getPayloadJson()); + message.setDeepLinkPath(StrUtil.blankToDefault(dto.getDeepLinkPath(), AppMessageConstants.MESSAGE_DETAIL_PATH)); + message.setDeepLinkParamsJson(dto.getDeepLinkParamsJson()); + message.setReadStatus(AppMessageConstants.UNREAD); + message.setOccurredTime(dto.getOccurredTime()); + try { + if (messageMapper.insert(message) != 1 || message.getId() == null) { + throw new IllegalStateException("写入有房通知消息失败"); + } + } + catch (DuplicateKeyException conflict) { + return true; + } + String skipReason = settingService.isUserPushEnabled(dto.getRecipientUserId()) + ? null + : AppMessageConstants.SKIP_USER_DISABLED; + deliveryService.createDelivery(message, skipReason); + return true; + } + + private boolean ownerMessageExists(String eventId, Long userId) { + Long count = messageMapper.selectCount(Wrappers.lambdaQuery() + .eq(ThirdAppMessageEntity::getEventId, eventId) + .eq(ThirdAppMessageEntity::getRecipientUserId, userId)); + return count != null && count > 0; + } + + private static String trimToMax(String raw, int max) { + String text = StrUtil.nullToEmpty(raw).trim(); + if (text.length() <= max) { + return text; + } + if (max <= 1) { + return text.substring(0, max); + } + return text.substring(0, max - 1) + "…"; + } +}