酒店,消息推送

This commit is contained in:
zjf committed 2026-10-23 17:10:42 +08:00
1 parent 588f34bef5
commit 47a1fb2a27
18 files changed
+1147 -6

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;
}
@@ -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)
@@ -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;
@@ -120,6 +122,8 @@ public class SmHotelAppController {
private final SmHotelProductMapper productMapper;
private final SmHotelAvailabilityNotifyService availabilityNotifyService;
private final StringRedisTemplate stringRedisTemplate;
@Operation(summary = "列表筛选项(区域/星级/设施)")
@@ -879,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,
@@ -1410,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> {
}
@@ -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;
@@ -112,6 +113,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
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()
@@ -1928,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) + "…";
}
}