This commit is contained in:
dzc committed 2026-10-26 14:34:14 +08:00
commit 413394014e
21 files changed
+1303 -72

No files matched your search

+3
View File
@@ -66,6 +66,9 @@ node_modules
### 充值导入测试脚本(含 Excel 原始数据与本地配置) ###
scripts/recharge-import/
#文档#
*.md
### AI 辅助工具 ###
.video_agent
.mimosa
@@ -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<SmHotelAvailSubEntity> {
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;
}
@@ -163,4 +163,13 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
@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;
}
@@ -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<SmHotelRoomEntity> {
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)
@@ -84,6 +84,9 @@ public class SmHotelOrderDetailVo {
private BigDecimal latitude;
private Long deptId;
@Schema(description = "酒店归属部门名称(商户)")
private String deptName;
}
@Data
@@ -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<SmHotelEntity> pageByHotelNameKeyword(Page<SmHotelEntity> page, String keyword, String city,
String district, Long districtAdcode, List<Integer> 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<SmHotelEntity> 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<Integer> 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<Integer> stars = starList == null || starList.isEmpty() ? null : starList;
List<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelEntity> rankKeywordHits(List<SmHotelEntity> rows, Long adFrom, Long adTo) {
if (rows == null || rows.isEmpty()) {
return List.of();
}
List<SmHotelEntity> inCity = new ArrayList<>();
List<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelAvailSubEntity> availabilitySubscribe(@RequestBody Map<String, Object> 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<Boolean> availabilityUnsubscribe(@RequestBody Map<String, Object> 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<Page<SmHotelAvailSubEntity>> 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<Map<String, Object>> 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<Page<SmHotelDailyEntity>> 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;
}
}
}
@@ -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<Map<String, Object>> scanAvailNotify(@PathVariable Long hotelId) {
return R.ok(availabilityNotifyService.scanNow(hotelId));
}
@SysLog("本地酒店删除日历")
@DeleteMapping("/local/{hotelId}/daily/{dailyId}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
@@ -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<SmHotelAvailSubEntity> {
}
@@ -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<SmHotelEntity> {
int insertBatch(@Param("list") List<SmHotelEntity> list);
/**
* App 店名前缀搜索:走 idx_hotel_name,可带区划区间;带 ORDER BY 安全(前缀命中集小)。
* 仅酒店表:店名前缀(走 idx_hotel_name)。几万级酒店 + LIMIT,秒级内。
*/
@Select("""
<script>
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 FORCE INDEX (idx_hotel_name)
WHERE del_flag = '0'
AND (upstream_status IS NULL OR upstream_status &lt;&gt; 'OFFLINE')
AND (hotel_name LIKE CONCAT(#{keyword}, '%') OR hotel_code = #{keyword})
<if test="adFrom != null">
AND district_adcode &gt;= #{adFrom} AND district_adcode &lt; #{adTo}
</if>
<if test="starLevels != null and starLevels.size() &gt; 0">
AND star_level IN
<foreach collection="starLevels" item="s" open="(" separator="," close=")">#{s}</foreach>
</if>
ORDER BY IFNULL(star_level, 0) DESC, id DESC
LIMIT #{limit}
</script>
""")
List<SmHotelEntity> selectAppByNamePrefix(@Param("keyword") String keyword, @Param("adFrom") Long adFrom,
@Param("adTo") Long adTo, @Param("starLevels") Collection<Integer> starLevels,
@Param("limit") int limit);
/**
* App 店名包含搜索:禁止 ORDER BY(含匹配 + 排序 = 全表 filesort 超时)。
* 先 LIMIT 取候选,由调用方内存排序。有 adcode 时扫描范围极小。
*/
@Select("""
<script>
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 &lt;&gt; 'OFFLINE')
AND hotel_name LIKE CONCAT('%', #{keyword}, '%')
<if test="adFrom != null">
AND district_adcode &gt;= #{adFrom} AND district_adcode &lt; #{adTo}
</if>
<if test="starLevels != null and starLevels.size() &gt; 0">
AND star_level IN
<foreach collection="starLevels" item="s" open="(" separator="," close=")">#{s}</foreach>
</if>
AND (upstream_status IS NULL OR upstream_status <> 'OFFLINE')
AND (hotel_name LIKE CONCAT(#{keyword}, '%') OR hotel_code = #{keyword})
ORDER BY star_level DESC, id DESC
LIMIT #{limit}
</script>
""")
List<SmHotelEntity> selectAppByNameContains(@Param("keyword") String keyword, @Param("adFrom") Long adFrom,
@Param("adTo") Long adTo, @Param("starLevels") Collection<Integer> starLevels,
@Param("limit") int limit);
List<SmHotelEntity> 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<SmHotelEntity> selectAppByNameContains(@Param("keyword") String keyword, @Param("limit") int limit);
}
@@ -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<SmHotelAvailSubEntity> subscribe(Long userId, Long hotelId, String productCode,
LocalDate checkIn, LocalDate checkOut, Integer rooms, String watchMode);
/**
* 取消订阅(优先按 id;否则按同键)。
*/
R<Boolean> unsubscribe(Long userId, Long id, Long hotelId, String productCode,
LocalDate checkIn, LocalDate checkOut, Integer rooms, String watchMode);
/**
* 当前用户订阅分页。
*/
Page<SmHotelAvailSubEntity> pageMine(Page<?> page, Long userId);
/**
* 是否已订阅:匹配指定日期 RANGE,或同产品同间数的 ANY。
*/
Map<String, Object> status(Long userId, Long hotelId, String productCode,
LocalDate checkIn, LocalDate checkOut, Integer rooms);
/**
* 酒店日历/库存变化后防抖扫描 ACTIVE 订阅并推送。
*/
void scheduleCheckAfterHotelChanged(Long hotelId);
/**
* 立即扫描指定酒店的 ACTIVE 有房订阅(运维/联调手动触发)。
*/
Map<String, Object> scanNow(Long hotelId);
}
@@ -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<Long, ScheduledFuture<?>> 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<SmHotelAvailSubEntity> 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.<SmHotelProductEntity>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<Boolean> 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<SmHotelAvailSubEntity> pageMine(Page<?> page, Long userId) {
return availSubMapper.selectPage(
new Page<>(page.getCurrent(), page.getSize()),
Wrappers.<SmHotelAvailSubEntity>lambdaQuery()
.eq(SmHotelAvailSubEntity::getUserId, userId)
.ne(SmHotelAvailSubEntity::getStatus, SmHotelAvailSubEntity.STATUS_CANCELLED)
.orderByDesc(SmHotelAvailSubEntity::getUpdateTime)
.orderByDesc(SmHotelAvailSubEntity::getId));
}
@Override
public Map<String, Object> status(Long userId, Long hotelId, String productCode,
LocalDate checkIn, LocalDate checkOut, Integer rooms) {
Map<String, Object> 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.<SmHotelAvailSubEntity>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.<SmHotelAvailSubEntity>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<String, Object> scanNow(Long hotelId) {
if (hotelId == null) {
Map<String, Object> 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<String, Object> doScanAndNotify(Long hotelId) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("hotelId", hotelId);
List<SmHotelAvailSubEntity> subs = availSubMapper.selectList(Wrappers.<SmHotelAvailSubEntity>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<Map<String, Object>> subBrief = new ArrayList<>();
for (SmHotelAvailSubEntity s : subs) {
Map<String, Object> 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<LocalDate> 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<String, Object> 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<String, Object> 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<Boolean> 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.<SmHotelAvailSubEntity>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<SmHotelDailyEntity> rows = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>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<LocalDate> missingBookableNights(Long hotelId, String productCode, LocalDate checkIn,
LocalDate checkOut, int rooms) {
List<LocalDate> 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<LocalDate> nights = new ArrayList<>();
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
nights.add(d);
}
List<SmHotelDailyEntity> rows = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getHotelId, hotelId)
.eq(SmHotelDailyEntity::getProductCode, productCode)
.ge(SmHotelDailyEntity::getStayDate, checkIn)
.lt(SmHotelDailyEntity::getStayDate, checkOut));
Set<LocalDate> 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.<SmHotelAvailSubEntity>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);
}
}
@@ -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<SmHotelProductEntity> 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) {
@@ -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<SmHotelOrderMapper, SmH
private final SmHotelRoomMapper roomMapper;
private final RemoteDeptService remoteDeptService;
private final SmHotelAvailabilityNotifyService availabilityNotifyService;
@Override
public Page<SmHotelOrderEntity> pageOrders(Page<SmHotelOrderEntity> page, SmHotelOrderEntity query) {
Page<SmHotelOrderEntity> result = baseMapper.selectPage(page, Wrappers.<SmHotelOrderEntity>lambdaQuery()
@@ -343,6 +350,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (hotel != null) {
o.setHotelName(hotel.getHotelName());
o.setHotelDataSource(upstreamGatewayRegistry.resolveChannelKey(hotel.getSheetName()));
o.setDeptId(hotel.getDeptId());
}
SmHotelProductEntity product = null;
if (StrUtil.isNotBlank(o.getProductCode())) {
@@ -380,6 +388,39 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
fillOrderSourceFields(o, app, partnerNameMap);
}
fillOrderDeptNames(records);
}
/** 批量补全酒店归属部门名(结算商户) */
private void fillOrderDeptNames(List<SmHotelOrderEntity> records) {
List<Long> deptIds = records.stream()
.map(SmHotelOrderEntity::getDeptId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (deptIds.isEmpty()) {
return;
}
Map<Long, String> nameById = new HashMap<>();
try {
R<Map<Long, SysDept>> 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<String, String> partnerNameMap,
@@ -522,10 +563,31 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
hb.setLongitude(hotel.getLongitude());
hb.setLatitude(hotel.getLatitude());
hb.setDeptId(hotel.getDeptId());
if (hotel.getDeptId() != null) {
try {
R<Map<Long, SysDept>> 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<SmHotelOrderMapper, SmH
}
}
order.setInventoryStatus(SmHotelInventoryStatus.ROLLED_BACK);
// 回补后可能重新可订,触发有房通知扫描
availabilityNotifyService.scheduleCheckAfterHotelChanged(order.getHotelId());
}
private BigDecimal sumLocalSellAmount(Long productId, LocalDate checkIn, LocalDate checkOut, int rooms) {
@@ -5,13 +5,14 @@
<insert id="insertBatch">
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
<foreach collection="list" item="e" separator=",">
(
#{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}
)
</foreach>
@@ -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<Boolean> receiveMealVerifiedEvent(@Valid @RequestBody MealVerifiedEventDto dto);
/**
* 幂等接收酒店有房通知(旁路,不进商户策略矩阵)。
*
* @param dto 超市酒店订阅触发的事件快照
* @return true表示消息中心已幂等接收
*/
@NoToken
@PostMapping("/app-message/inner/hotel-room-available-event")
R<Boolean> receiveHotelRoomAvailableEvent(@Valid @RequestBody HotelRoomAvailableEventDto dto);
}
@@ -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;
}
@@ -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";
@@ -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<Boolean> receiveHotelRoomAvailableEvent(@Valid @RequestBody HotelRoomAvailableEventDto dto) {
return R.ok(TenantBroker.applyAs(dto.getTenantId(), tenantId -> hotelRoomNotifyService.receive(dto)));
}
/**
* 分页查询当前用户全部或未读消息。
*
@@ -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);
}
@@ -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));
@@ -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.<ThirdAppMessageEntity>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) + "…";
}
}