酒店
This commit is contained in:
1 parent
a74dec5e7a
commit
04a39fbeff
14 files changed
+910
-101
No files matched your search
+13
@@ -13,6 +13,15 @@ public final class SmHotelOpenApi {
|
||||
|
||||
public static final String HOTEL_DAILY = BASE + "/hotel/daily";
|
||||
|
||||
/** 授权范围内筛选项(区域 adcode/名称、商圈、星级等) */
|
||||
public static final String HOTEL_FILTER_META = BASE + "/hotel/filter-meta";
|
||||
|
||||
/**
|
||||
* 增量同步拉取(下游落库用):按 updateTime 水位翻页,可含下架。
|
||||
* 路径仍走 list 增强参数亦可;独立路径便于限流与文档。
|
||||
*/
|
||||
public static final String HOTEL_SYNC = BASE + "/hotel/sync";
|
||||
|
||||
public static final String ORDER_CHECK = BASE + "/order/check";
|
||||
|
||||
public static final String ORDER_CREATE = BASE + "/order/create";
|
||||
@@ -28,6 +37,10 @@ public final class SmHotelOpenApi {
|
||||
|
||||
public static final String API_HOTEL_DAILY = "hotel.daily";
|
||||
|
||||
public static final String API_HOTEL_FILTER_META = "hotel.filterMeta";
|
||||
|
||||
public static final String API_HOTEL_SYNC = "hotel.sync";
|
||||
|
||||
public static final String API_ORDER_CHECK = "order.check";
|
||||
|
||||
public static final String API_ORDER_CREATE = "order.create";
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.bsd.pjcloud.supermarket.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 酒店下游开放平台配置。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "sm-hotel.open")
|
||||
public class SmHotelOpenProperties {
|
||||
|
||||
/**
|
||||
* 对外开放的文件/图片根地址(含网关前缀,无尾斜杠),例如:
|
||||
* https://www.zianxin.com/api
|
||||
* <p>
|
||||
* 库内相对路径(如 /admin/sys-file/oss/file?fileName=xx)会拼到此前缀后下发;
|
||||
* 已是 http(s) 的上游图原样返回。未配置时仍返回库内原值(下游可能无法展示)。
|
||||
*/
|
||||
private String publicFileBaseUrl = "";
|
||||
}
|
||||
+211
-59
@@ -86,6 +86,14 @@ public class SmHotelAppController {
|
||||
|
||||
private static final String CANDIDATE_CACHE_OVERFLOW = "OVERFLOW";
|
||||
|
||||
/** 列表页结果短缓存(同条件重复进入/下拉) */
|
||||
private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:";
|
||||
|
||||
private static final long PAGE_CACHE_SECONDS = 45L;
|
||||
|
||||
/** 先按区域/关键字缩小酒店集合时的上限,超出则回退 EXISTS */
|
||||
private static final int MAX_SCOPED_HOTEL_IDS = 3000;
|
||||
|
||||
private final SmHotelService hotelService;
|
||||
|
||||
private final SmHotelMidService midService;
|
||||
@@ -219,8 +227,95 @@ public class SmHotelAppController {
|
||||
@RequestParam(required = false) Integer guests,
|
||||
@RequestParam(required = false) String checkIn,
|
||||
@RequestParam(required = false) String checkOut) {
|
||||
long t0 = System.currentTimeMillis();
|
||||
List<Integer> starList = parseIntCsv(starLevels);
|
||||
List<String> facilityList = parseStrCsv(facilityTags);
|
||||
LocalDate stayFrom = parseDay(checkIn);
|
||||
LocalDate stayToExclusive = parseDay(checkOut);
|
||||
int guestTotal = resolveGuestTotal(guests, adults, children);
|
||||
int roomCnt = rooms == null || rooms < 1 ? 1 : Math.min(rooms, 20);
|
||||
int needCap = guestTotal > 0 ? Math.max(1, (guestTotal + roomCnt - 1) / roomCnt) : 1;
|
||||
|
||||
String pageCacheKey = buildPageCacheKey(page.getCurrent(), page.getSize(), keyword, city, district,
|
||||
districtAdcode, bizArea, starLevels, minScore, minPrice, maxPrice, facilityTags, rooms, adults,
|
||||
children, guests, checkIn, checkOut);
|
||||
Page<Map<String, Object>> cached = readPageCache(pageCacheKey);
|
||||
if (cached != null) {
|
||||
log.info("|酒店App|page命中缓存|current={}|size={}|costMs={}|", page.getCurrent(), page.getSize(),
|
||||
System.currentTimeMillis() - t0);
|
||||
return R.ok(cached);
|
||||
}
|
||||
|
||||
var wrapper = baseOnlineListWrapper(keyword, city, district, districtAdcode, bizArea, starList, minScore,
|
||||
facilityList);
|
||||
// 列表不查 intro/carousel 大字段,显著降低 IO 与加密体积
|
||||
wrapper.select(SmHotelEntity::getId, SmHotelEntity::getHotelName, SmHotelEntity::getHotelCode,
|
||||
SmHotelEntity::getAddress, SmHotelEntity::getDistrict, SmHotelEntity::getDistrictAdcode,
|
||||
SmHotelEntity::getBizArea, SmHotelEntity::getStarLevel, SmHotelEntity::getScore,
|
||||
SmHotelEntity::getFacilityTags, SmHotelEntity::getCoverImage, SmHotelEntity::getPhone,
|
||||
SmHotelEntity::getSheetName, SmHotelEntity::getUpstreamStatus, SmHotelEntity::getUpdateTime);
|
||||
|
||||
boolean hasScopeFilter = districtAdcode != null || StrUtil.isNotBlank(district) || StrUtil.isNotBlank(city)
|
||||
|| StrUtil.isNotBlank(keyword) || StrUtil.isNotBlank(bizArea) || !starList.isEmpty()
|
||||
|| minScore != null || !facilityList.isEmpty();
|
||||
List<Long> scopedIds = null;
|
||||
if (hasScopeFilter) {
|
||||
scopedIds = loadScopedHotelIds(keyword, city, district, districtAdcode, bizArea, starList, minScore,
|
||||
facilityList);
|
||||
if (scopedIds != null && scopedIds.isEmpty()) {
|
||||
return R.ok(emptyPage(page));
|
||||
}
|
||||
}
|
||||
|
||||
// 有城市/关键字时:先在缩小后的酒店集合内查日历,避免全库 DISTINCT
|
||||
List<Long> candidateIds = resolveAppListCandidateHotelIds(stayFrom, stayToExclusive, needCap, scopedIds);
|
||||
if (candidateIds != null) {
|
||||
if (candidateIds.isEmpty()) {
|
||||
return R.ok(emptyPage(page));
|
||||
}
|
||||
wrapper.in(SmHotelEntity::getId, candidateIds);
|
||||
}
|
||||
else {
|
||||
// 候选过多或未预筛:EXISTS / IN(子查询),配合 idx_daily_hotel_stay
|
||||
applyBookableHotelFilter(wrapper, stayFrom, stayToExclusive);
|
||||
applyGuestCapacityFilter(wrapper, needCap);
|
||||
}
|
||||
if (minPrice != null || maxPrice != null) {
|
||||
List<Long> priceHitIds = loadHotelIdsByMinPriceRange(minPrice, maxPrice);
|
||||
if (priceHitIds.isEmpty()) {
|
||||
return R.ok(emptyPage(page));
|
||||
}
|
||||
wrapper.in(SmHotelEntity::getId, priceHitIds);
|
||||
}
|
||||
wrapper.orderByDesc(SmHotelEntity::getScore)
|
||||
.orderByDesc(SmHotelEntity::getUpdateTime)
|
||||
.orderByDesc(SmHotelEntity::getId);
|
||||
|
||||
Page<SmHotelEntity> raw = hotelService.page(page, wrapper);
|
||||
List<Long> ids = raw.getRecords().stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList();
|
||||
Map<Long, BigDecimal> minPriceMap = loadMinSellPrice(ids);
|
||||
Page<Map<String, Object>> out = new Page<>(raw.getCurrent(), raw.getSize(), raw.getTotal());
|
||||
out.setRecords(raw.getRecords().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(h -> toListRow(h, minPriceMap.get(h.getId())))
|
||||
.toList());
|
||||
writePageCache(pageCacheKey, out);
|
||||
log.info("|酒店App|page|current={}|size={}|total={}|scope={}|cand={}|costMs={}|", page.getCurrent(),
|
||||
page.getSize(), raw.getTotal(), scopedIds == null ? -1 : scopedIds.size(),
|
||||
candidateIds == null ? -1 : candidateIds.size(), System.currentTimeMillis() - t0);
|
||||
return R.ok(out);
|
||||
}
|
||||
|
||||
private static Page<Map<String, Object>> emptyPage(Page<SmHotelEntity> page) {
|
||||
Page<Map<String, Object>> empty = new Page<>(page.getCurrent(), page.getSize(), 0);
|
||||
empty.setRecords(List.of());
|
||||
return empty;
|
||||
}
|
||||
|
||||
/** 上架 + 区域/关键字/星级等公共条件(不含可售日历、排序) */
|
||||
private LambdaQueryWrapper<SmHotelEntity> baseOnlineListWrapper(String keyword, String city, String district,
|
||||
Long districtAdcode, String bizArea, List<Integer> starList, BigDecimal minScore,
|
||||
List<String> facilityList) {
|
||||
var wrapper = Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.and(w -> w.isNull(SmHotelEntity::getUpstreamStatus)
|
||||
.or()
|
||||
@@ -234,12 +329,12 @@ public class SmHotelAppController {
|
||||
.like(SmHotelEntity::getBizArea, keyword.trim())
|
||||
.or()
|
||||
.like(SmHotelEntity::getHotelCode, keyword.trim()))
|
||||
.and(districtAdcode == null && StrUtil.isNotBlank(city), w -> w.like(SmHotelEntity::getAddress, city.trim())
|
||||
.or()
|
||||
.like(SmHotelEntity::getDistrict, city.trim())
|
||||
.or()
|
||||
.like(SmHotelEntity::getHotelName, city.trim()));
|
||||
// 有 adcode:数值区间走索引 + 名称兜底;仅名称:保持精确匹配(与历史 App 行为一致)
|
||||
.and(districtAdcode == null && StrUtil.isNotBlank(city),
|
||||
w -> w.like(SmHotelEntity::getAddress, city.trim())
|
||||
.or()
|
||||
.like(SmHotelEntity::getDistrict, city.trim())
|
||||
.or()
|
||||
.like(SmHotelEntity::getHotelName, city.trim()));
|
||||
if (districtAdcode != null) {
|
||||
HotelAreaAdcodeUtil.applyHotelListFilter(wrapper, districtAdcode, district);
|
||||
}
|
||||
@@ -248,53 +343,30 @@ public class SmHotelAppController {
|
||||
}
|
||||
wrapper.like(StrUtil.isNotBlank(bizArea), SmHotelEntity::getBizArea, StrUtil.trim(bizArea))
|
||||
.in(!starList.isEmpty(), SmHotelEntity::getStarLevel, starList)
|
||||
.ge(minScore != null, SmHotelEntity::getScore, minScore)
|
||||
.orderByDesc(SmHotelEntity::getScore)
|
||||
.orderByDesc(SmHotelEntity::getUpdateTime)
|
||||
.orderByDesc(SmHotelEntity::getId);
|
||||
.ge(minScore != null, SmHotelEntity::getScore, minScore);
|
||||
for (String tag : facilityList) {
|
||||
wrapper.like(SmHotelEntity::getFacilityTags, tag);
|
||||
}
|
||||
LocalDate stayFrom = parseDay(checkIn);
|
||||
LocalDate stayToExclusive = parseDay(checkOut);
|
||||
int guestTotal = resolveGuestTotal(guests, adults, children);
|
||||
int roomCnt = rooms == null || rooms < 1 ? 1 : Math.min(rooms, 20);
|
||||
int needCap = guestTotal > 0 ? Math.max(1, (guestTotal + roomCnt - 1) / roomCnt) : 1;
|
||||
// 先查候选酒店 id(DISTINCT),再 IN;避免对大表相关 EXISTS + ORDER BY 拖慢分页
|
||||
List<Long> candidateIds = resolveAppListCandidateHotelIds(stayFrom, stayToExclusive, needCap);
|
||||
if (candidateIds != null) {
|
||||
if (candidateIds.isEmpty()) {
|
||||
Page<Map<String, Object>> empty = new Page<>(page.getCurrent(), page.getSize(), 0);
|
||||
empty.setRecords(List.of());
|
||||
return R.ok(empty);
|
||||
}
|
||||
wrapper.in(SmHotelEntity::getId, candidateIds);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 先按区域/关键字等缩小酒店 id;过大则返回 null(调用方走 EXISTS,避免超长 IN)。
|
||||
*/
|
||||
private List<Long> loadScopedHotelIds(String keyword, String city, String district, Long districtAdcode,
|
||||
String bizArea, List<Integer> starList, BigDecimal minScore, List<String> facilityList) {
|
||||
var w = baseOnlineListWrapper(keyword, city, district, districtAdcode, bizArea, starList, minScore,
|
||||
facilityList);
|
||||
w.select(SmHotelEntity::getId);
|
||||
w.last("LIMIT " + (MAX_SCOPED_HOTEL_IDS + 1));
|
||||
List<SmHotelEntity> rows = hotelService.list(w);
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
else {
|
||||
// 候选过多:用 IN(子查询) 替代相关 EXISTS,语义不变、更易走 stay_date 索引
|
||||
applyBookableHotelFilter(wrapper, stayFrom, stayToExclusive);
|
||||
applyGuestCapacityFilter(wrapper, needCap);
|
||||
if (rows.size() > MAX_SCOPED_HOTEL_IDS) {
|
||||
return null;
|
||||
}
|
||||
// 起价区间:按产品最低卖价过滤(分页前约束)
|
||||
if (minPrice != null || maxPrice != null) {
|
||||
List<Long> priceHitIds = loadHotelIdsByMinPriceRange(minPrice, maxPrice);
|
||||
if (priceHitIds.isEmpty()) {
|
||||
Page<Map<String, Object>> empty = new Page<>(page.getCurrent(), page.getSize(), 0);
|
||||
empty.setRecords(List.of());
|
||||
return R.ok(empty);
|
||||
}
|
||||
wrapper.in(SmHotelEntity::getId, priceHitIds);
|
||||
}
|
||||
// 移动端 TOC 不走管理端数据权限,按租户查已上架酒店
|
||||
Page<SmHotelEntity> raw = hotelService.page(page, wrapper);
|
||||
List<Long> ids = raw.getRecords().stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList();
|
||||
Map<Long, BigDecimal> minPriceMap = loadMinSellPrice(ids);
|
||||
Page<Map<String, Object>> out = new Page<>(raw.getCurrent(), raw.getSize(), raw.getTotal());
|
||||
out.setRecords(raw.getRecords().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(h -> toListRow(h, minPriceMap.get(h.getId())))
|
||||
.toList());
|
||||
return R.ok(out);
|
||||
return rows.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).distinct().toList();
|
||||
}
|
||||
|
||||
@Operation(summary = "读取当前用户酒店搜索习惯")
|
||||
@@ -632,16 +704,27 @@ public class SmHotelAppController {
|
||||
* 解析列表候选酒店:
|
||||
* 1) 入住区间内有日历;
|
||||
* 2) 可选:房型可住人数达标。
|
||||
* 返回 null 表示候选过多,调用方回退 IN(子查询)。
|
||||
* @param scopedHotelIds 区域/关键字等已缩小的酒店集合;null=全国未缩小(不预加载全库 DISTINCT,回退 EXISTS)
|
||||
* @return null 表示调用方应回退 EXISTS / IN(子查询)
|
||||
*/
|
||||
private List<Long> resolveAppListCandidateHotelIds(LocalDate stayFrom, LocalDate stayToExclusive, int needCapPerRoom) {
|
||||
private List<Long> resolveAppListCandidateHotelIds(LocalDate stayFrom, LocalDate stayToExclusive,
|
||||
int needCapPerRoom, List<Long> scopedHotelIds) {
|
||||
LocalDate from = stayFrom != null ? stayFrom : LocalDate.now();
|
||||
LocalDate toEx = stayToExclusive;
|
||||
if (toEx == null || !toEx.isAfter(from)) {
|
||||
// 未传离店:只扫未来 30 天有日历的酒店,避免无上界扫全表
|
||||
toEx = from.plusDays(30);
|
||||
// 未传离店:只扫入住日起 2 天(列表展示够用),避免默认扫 30 天全库
|
||||
toEx = from.plusDays(2);
|
||||
}
|
||||
String cacheKey = CANDIDATE_CACHE_PREFIX + from + ":" + toEx + ":" + needCapPerRoom;
|
||||
// 全国无缩小范围:不要 SELECT DISTINCT 全库日历(百万级会拖死),交给 EXISTS
|
||||
if (scopedHotelIds == null) {
|
||||
return null;
|
||||
}
|
||||
if (scopedHotelIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
String scopeDigest = Integer.toHexString(scopedHotelIds.hashCode());
|
||||
String cacheKey = CANDIDATE_CACHE_PREFIX + from + ":" + toEx + ":" + needCapPerRoom + ":" + scopeDigest + ":"
|
||||
+ scopedHotelIds.size();
|
||||
try {
|
||||
String cached = stringRedisTemplate.opsForValue().get(cacheKey);
|
||||
if (CANDIDATE_CACHE_EMPTY.equals(cached)) {
|
||||
@@ -661,7 +744,7 @@ public class SmHotelAppController {
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店App|候选缓存读取失败|{}", e.getMessage());
|
||||
}
|
||||
List<Long> bookable = dailyMapper.selectDistinctHotelIdsByStayRange(from, toEx);
|
||||
List<Long> bookable = dailyMapper.selectDistinctHotelIdsByStayRangeInHotels(from, toEx, scopedHotelIds);
|
||||
if (bookable == null || bookable.isEmpty()) {
|
||||
cacheCandidateIds(cacheKey, CANDIDATE_CACHE_EMPTY);
|
||||
return List.of();
|
||||
@@ -697,18 +780,87 @@ public class SmHotelAppController {
|
||||
}
|
||||
}
|
||||
|
||||
/** 可售:IN(日历 DISTINCT 子查询),替代相关 EXISTS */
|
||||
private String buildPageCacheKey(long current, long size, String keyword, String city, String district,
|
||||
Long districtAdcode, String bizArea, String starLevels, BigDecimal minScore, BigDecimal minPrice,
|
||||
BigDecimal maxPrice, String facilityTags, Integer rooms, Integer adults, Integer children,
|
||||
Integer guests, String checkIn, String checkOut) {
|
||||
return PAGE_CACHE_PREFIX + current + ":" + size + ":" + StrUtil.nullToEmpty(keyword) + ":"
|
||||
+ StrUtil.nullToEmpty(city) + ":" + StrUtil.nullToEmpty(district) + ":"
|
||||
+ (districtAdcode == null ? "" : districtAdcode) + ":" + StrUtil.nullToEmpty(bizArea) + ":"
|
||||
+ StrUtil.nullToEmpty(starLevels) + ":" + (minScore == null ? "" : minScore) + ":"
|
||||
+ (minPrice == null ? "" : minPrice) + ":" + (maxPrice == null ? "" : maxPrice) + ":"
|
||||
+ StrUtil.nullToEmpty(facilityTags) + ":" + rooms + ":" + adults + ":" + children + ":" + guests
|
||||
+ ":" + StrUtil.nullToEmpty(checkIn) + ":" + StrUtil.nullToEmpty(checkOut);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Page<Map<String, Object>> readPageCache(String key) {
|
||||
try {
|
||||
String cached = stringRedisTemplate.opsForValue().get(key);
|
||||
if (StrUtil.isBlank(cached)) {
|
||||
return null;
|
||||
}
|
||||
JSONObject obj = JSONUtil.parseObj(cached);
|
||||
Page<Map<String, Object>> p = new Page<>(obj.getLong("current", 1L), obj.getLong("size", 10L),
|
||||
obj.getLong("total", 0L));
|
||||
Object records = obj.get("records");
|
||||
if (records instanceof List<?> list) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>(list.size());
|
||||
for (Object o : list) {
|
||||
if (o instanceof Map<?, ?> m) {
|
||||
rows.add(new LinkedHashMap<>((Map<String, Object>) m));
|
||||
}
|
||||
else if (o != null) {
|
||||
rows.add(JSONUtil.parseObj(JSONUtil.toJsonStr(o)));
|
||||
}
|
||||
}
|
||||
p.setRecords(rows);
|
||||
}
|
||||
else {
|
||||
p.setRecords(List.of());
|
||||
}
|
||||
return p;
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店App|page缓存读取失败|{}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void writePageCache(String key, Page<Map<String, Object>> page) {
|
||||
if (page == null || StrUtil.isBlank(key)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> payload = new LinkedHashMap<>(4);
|
||||
payload.put("current", page.getCurrent());
|
||||
payload.put("size", page.getSize());
|
||||
payload.put("total", page.getTotal());
|
||||
payload.put("records", page.getRecords());
|
||||
stringRedisTemplate.opsForValue()
|
||||
.set(key, JSONUtil.toJsonStr(payload), PAGE_CACHE_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|酒店App|page缓存写入失败|{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可售:EXISTS 按 hotel_id 点查日历(配 idx_daily_hotel_stay)。
|
||||
* 全国列表走此路径,避免先 DISTINCT 全库再超长 IN。
|
||||
*/
|
||||
private void applyBookableHotelFilter(LambdaQueryWrapper<SmHotelEntity> wrapper, LocalDate stayFrom,
|
||||
LocalDate stayToExclusive) {
|
||||
LocalDate from = stayFrom != null ? stayFrom : LocalDate.now();
|
||||
LocalDate toEx = stayToExclusive;
|
||||
if (toEx == null || !toEx.isAfter(from)) {
|
||||
toEx = from.plusDays(30);
|
||||
toEx = from.plusDays(2);
|
||||
}
|
||||
wrapper.apply("""
|
||||
id IN (
|
||||
SELECT DISTINCT d.hotel_id FROM canteen_hotel_daily d
|
||||
WHERE d.del_flag = '0'
|
||||
EXISTS (
|
||||
SELECT 1 FROM canteen_hotel_daily d
|
||||
WHERE d.hotel_id = id
|
||||
AND d.del_flag = '0'
|
||||
AND d.stay_date >= {0}
|
||||
AND d.stay_date < {1}
|
||||
)
|
||||
|
||||
+16
@@ -63,6 +63,22 @@ public class SmHotelOpenApiController {
|
||||
(app, body) -> openApiService.hotelDaily(app, body));
|
||||
}
|
||||
|
||||
@Inner(value = false)
|
||||
@Operation(summary = "筛选项(授权范围内区域/商圈/星级)")
|
||||
@PostMapping("/hotel/filter-meta")
|
||||
public R<Map<String, Object>> hotelFilterMeta(HttpServletRequest request, @RequestBody String rawBody) {
|
||||
return guarded(request, rawBody, SmHotelOpenApi.API_HOTEL_FILTER_META,
|
||||
(app, body) -> openApiService.hotelFilterMeta(app, body));
|
||||
}
|
||||
|
||||
@Inner(value = false)
|
||||
@Operation(summary = "增量同步拉取(下游落库)")
|
||||
@PostMapping("/hotel/sync")
|
||||
public R<Map<String, Object>> hotelSync(HttpServletRequest request, @RequestBody String rawBody) {
|
||||
return guarded(request, rawBody, SmHotelOpenApi.API_HOTEL_SYNC,
|
||||
(app, body) -> openApiService.hotelSync(app, body));
|
||||
}
|
||||
|
||||
@Inner(value = false)
|
||||
@Operation(summary = "下单前可订校验")
|
||||
@PostMapping("/order/check")
|
||||
|
||||
+47
-11
@@ -679,16 +679,18 @@ public class ShangyouHotelClient {
|
||||
|
||||
/**
|
||||
* 下单/按单号查询专用:不因业务码非 200 抛错(3028 等需补偿);HTTP 5xx/超时标 uncertain。
|
||||
* 业务码 500「系统异常/请稍后重试」按瞬时繁忙退避重试,耗尽后转 uncertain 走补偿。
|
||||
*/
|
||||
private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body) {
|
||||
return postOrderBiz(path, body, false, null);
|
||||
}
|
||||
|
||||
private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body, boolean retried) {
|
||||
return postOrderBiz(path, body, retried, null);
|
||||
return postOrderBiz(path, body, false, null, 0);
|
||||
}
|
||||
|
||||
private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body, boolean retried, String invalidToken) {
|
||||
return postOrderBiz(path, body, retried, invalidToken, 0);
|
||||
}
|
||||
|
||||
private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body, boolean retried, String invalidToken,
|
||||
int busyRound) {
|
||||
ensureEnabled();
|
||||
String token;
|
||||
try {
|
||||
@@ -698,7 +700,8 @@ public class ShangyouHotelClient {
|
||||
return ShangyouOrderCallResult.uncertain("取 Token 失败: " + e.getMessage(), null);
|
||||
}
|
||||
String url = props().resolveBizBaseUrl() + path;
|
||||
log.info("shangyou order request path={} retried={} bodyKeys={}", path, retried, body.keySet());
|
||||
log.info("shangyou order request path={} retried={} busyRound={} bodyKeys={}", path, retried, busyRound,
|
||||
body.keySet());
|
||||
try (HttpResponse response = HttpRequest.post(url)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Accept", "*/*")
|
||||
@@ -711,12 +714,18 @@ public class ShangyouHotelClient {
|
||||
int status = response.getStatus();
|
||||
if (status == 401 || isTokenExpiredBiz(respBody)) {
|
||||
if (!retried) {
|
||||
return postOrderBiz(path, body, true, token);
|
||||
return postOrderBiz(path, body, true, token, busyRound);
|
||||
}
|
||||
return ShangyouOrderCallResult.uncertain("Token 失效且重试仍失败: " + StrUtil.sub(respBody, 0, 300),
|
||||
status);
|
||||
}
|
||||
if (status >= 500 || status == 408 || status == 429) {
|
||||
if (busyRound < 4) {
|
||||
sleepQuiet(busyBackoffMs(busyRound));
|
||||
log.warn("shangyou order HTTP busy, retry path={} nextBusy={} status={}", path, busyRound + 1,
|
||||
status);
|
||||
return postOrderBiz(path, body, false, null, busyRound + 1);
|
||||
}
|
||||
return ShangyouOrderCallResult.uncertain("HTTP=" + status + " body=" + StrUtil.sub(respBody, 0, 300),
|
||||
status);
|
||||
}
|
||||
@@ -739,10 +748,22 @@ public class ShangyouHotelClient {
|
||||
JSONObject resp = JSON.parseObject(respBody);
|
||||
if (isTokenExpiredBiz(resp)) {
|
||||
if (!retried) {
|
||||
return postOrderBiz(path, body, true, token);
|
||||
return postOrderBiz(path, body, true, token, busyRound);
|
||||
}
|
||||
return ShangyouOrderCallResult.uncertain("Token 业务失效且重试仍失败", status);
|
||||
}
|
||||
// 业务码 500「系统异常:请稍后重试」等:与查价一致做退避重试
|
||||
if (isUpstreamBusy(resp) && busyRound < 4) {
|
||||
sleepQuiet(busyBackoffMs(busyRound));
|
||||
log.warn("shangyou order busy, retry path={} nextBusy={} msg={}", path, busyRound + 1,
|
||||
resp.getString("message"));
|
||||
return postOrderBiz(path, body, false, null, busyRound + 1);
|
||||
}
|
||||
if (isUpstreamBusy(resp)) {
|
||||
log.warn("shangyou order busy exhausted path={} msg={}", path, resp.getString("message"));
|
||||
return ShangyouOrderCallResult.uncertain(
|
||||
"上游繁忙/系统异常: " + StrUtil.blankToDefault(resp.getString("message"), "请稍后重试"), status);
|
||||
}
|
||||
log.info("|上游订单响应|path={}|code={}|message={}|snippet={}|", path, resp.getString("code"),
|
||||
resp.getString("message"), StrUtil.sub(respBody, 0, 500));
|
||||
return ShangyouOrderCallResult.ofResp(resp, status);
|
||||
@@ -753,6 +774,15 @@ public class ShangyouHotelClient {
|
||||
}
|
||||
}
|
||||
|
||||
private static long busyBackoffMs(int busyRound) {
|
||||
return switch (busyRound) {
|
||||
case 0 -> 800L;
|
||||
case 1 -> 1500L;
|
||||
case 2 -> 3000L;
|
||||
default -> 5000L;
|
||||
};
|
||||
}
|
||||
|
||||
public String resolveCallbackSecret() {
|
||||
return StrUtil.blankToDefault(props().getCallbackSecret(), props().getCardPassword());
|
||||
}
|
||||
@@ -897,17 +927,23 @@ public class ShangyouHotelClient {
|
||||
|| StrUtil.containsIgnoreCase(msg, "失效") || StrUtil.containsIgnoreCase(msg, "为空"));
|
||||
}
|
||||
|
||||
/** 上游瞬时繁忙(滚价高峰常见) */
|
||||
/** 上游瞬时繁忙(滚价高峰 / 系统异常:请稍后重试 常见) */
|
||||
private static boolean isUpstreamBusy(JSONObject resp) {
|
||||
if (resp == null) {
|
||||
return false;
|
||||
}
|
||||
String code = firstNonBlank(resp.getString("code"), resp.getString("Code"));
|
||||
String msg = firstNonBlank(resp.getString("message"), resp.getString("msg"));
|
||||
if ("500".equals(code) && StrUtil.containsIgnoreCase(msg, "繁忙")) {
|
||||
if (StrUtil.isBlank(msg)) {
|
||||
return false;
|
||||
}
|
||||
// 文档示例「繁忙」;实际常见「系统异常:请稍后重试」(稍后 ≠ 稍后再)
|
||||
if ("500".equals(code) && (StrUtil.containsIgnoreCase(msg, "繁忙") || StrUtil.contains(msg, "系统异常")
|
||||
|| StrUtil.contains(msg, "请稍后") || StrUtil.contains(msg, "请稍候"))) {
|
||||
return true;
|
||||
}
|
||||
return StrUtil.containsIgnoreCase(msg, "请稍后再试") || StrUtil.containsIgnoreCase(msg, "稍候再试");
|
||||
return StrUtil.containsIgnoreCase(msg, "请稍后再试") || StrUtil.containsIgnoreCase(msg, "稍候再试")
|
||||
|| StrUtil.contains(msg, "请稍后重试");
|
||||
}
|
||||
|
||||
private static void sleepQuiet(long ms) {
|
||||
|
||||
+16
@@ -42,6 +42,22 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper<SmHotelDailyEntity
|
||||
List<Long> selectDistinctHotelIdsByStayRange(@Param("fromDate") LocalDate fromDate,
|
||||
@Param("toExclusive") LocalDate toExclusive);
|
||||
|
||||
/**
|
||||
* 在给定酒店集合内查「入住区间有日历」的酒店(城市/关键字先缩小范围后再查,避免全库 DISTINCT)。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT DISTINCT hotel_id FROM canteen_hotel_daily
|
||||
WHERE del_flag = '0'
|
||||
AND stay_date >= #{fromDate}
|
||||
AND stay_date < #{toExclusive}
|
||||
AND hotel_id IN
|
||||
<foreach collection="hotelIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</script>
|
||||
""")
|
||||
List<Long> selectDistinctHotelIdsByStayRangeInHotels(@Param("fromDate") LocalDate fromDate,
|
||||
@Param("toExclusive") LocalDate toExclusive, @Param("hotelIds") Collection<Long> hotelIds);
|
||||
|
||||
int insertBatch(@Param("list") List<SmHotelDailyEntity> list);
|
||||
|
||||
/**
|
||||
|
||||
+6
@@ -17,6 +17,12 @@ public interface SmHotelOpenApiService {
|
||||
|
||||
R<Map<String, Object>> hotelDaily(SmHotelDownstreamAppEntity app, Map<String, Object> body);
|
||||
|
||||
/** 授权范围内区域/商圈/星级筛选项 */
|
||||
R<Map<String, Object>> hotelFilterMeta(SmHotelDownstreamAppEntity app, Map<String, Object> body);
|
||||
|
||||
/** 下游落库增量同步 */
|
||||
R<Map<String, Object>> hotelSync(SmHotelDownstreamAppEntity app, Map<String, Object> body);
|
||||
|
||||
R<Map<String, Object>> orderCheck(SmHotelDownstreamAppEntity app, Map<String, Object> body);
|
||||
|
||||
R<JSONObject> orderCreate(SmHotelDownstreamAppEntity app, Map<String, Object> body);
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.bsd.pjcloud.supermarket.service;
|
||||
|
||||
/**
|
||||
* 开放应用:酒店资源变更回调(按渠道授权范围扇出)。
|
||||
*/
|
||||
public interface SmHotelOpenResourceNotifyService {
|
||||
|
||||
String CHANGE_UPSERT = "HOTEL_UPSERT";
|
||||
|
||||
String CHANGE_OFFLINE = "HOTEL_OFFLINE";
|
||||
|
||||
String CHANGE_DELETE = "HOTEL_DELETE";
|
||||
|
||||
String CHANGE_DETAIL = "HOTEL_DETAIL";
|
||||
|
||||
/** 按酒店主键通知(自动解析 hotelCode) */
|
||||
void notifyByHotelId(Long hotelId, String changeType);
|
||||
|
||||
/** 按酒店编码通知(删除前可先记下 code) */
|
||||
void notifyByHotelCode(String hotelCode, Long hotelId, String changeType);
|
||||
}
|
||||
+10
-1
@@ -27,6 +27,7 @@ import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAutoPushService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelDetailVO;
|
||||
import com.bsd.pjcloud.common.core.constant.CommonConstants;
|
||||
@@ -88,6 +89,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
|
||||
private final SmHotelDownstreamAutoPushService downstreamAutoPushService;
|
||||
|
||||
private final SmHotelOpenResourceNotifyService openResourceNotifyService;
|
||||
|
||||
private final SmHotelMapper hotelMapper;
|
||||
|
||||
private final SmHotelRoomMapper roomMapper;
|
||||
@@ -102,7 +105,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
DownstreamHotelClient downstreamClient, HotelDownstreamProperties downstreamProperties,
|
||||
SmHotelPriceConfigService priceConfigService, SmHotelChannelConfigService channelConfigService,
|
||||
SmHotelChannelDeptService channelDeptService, SmHotelDownstreamPushLogService downstreamPushLogService,
|
||||
@Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService, SmHotelMapper hotelMapper,
|
||||
@Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService,
|
||||
@Lazy SmHotelOpenResourceNotifyService openResourceNotifyService, SmHotelMapper hotelMapper,
|
||||
SmHotelRoomMapper roomMapper, SmHotelProductMapper productMapper, SmHotelDailyMapper dailyMapper,
|
||||
StringRedisTemplate stringRedisTemplate) {
|
||||
this.upstreamClient = upstreamClient;
|
||||
@@ -114,6 +118,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
this.channelDeptService = channelDeptService;
|
||||
this.downstreamPushLogService = downstreamPushLogService;
|
||||
this.downstreamAutoPushService = downstreamAutoPushService;
|
||||
this.openResourceNotifyService = openResourceNotifyService;
|
||||
this.hotelMapper = hotelMapper;
|
||||
this.roomMapper = roomMapper;
|
||||
this.productMapper = productMapper;
|
||||
@@ -1490,6 +1495,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
roomMapper.updateById(room);
|
||||
}
|
||||
downstreamAutoPushService.scheduleAfterHotelChanged(hotelId);
|
||||
openResourceNotifyService.notifyByHotelId(hotelId, SmHotelOpenResourceNotifyService.CHANGE_DETAIL);
|
||||
return R.ok(roomMapper.selectById(room.getId()));
|
||||
}
|
||||
|
||||
@@ -1510,6 +1516,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
boolean ok = roomMapper.deleteById(roomId) > 0;
|
||||
if (ok) {
|
||||
downstreamAutoPushService.scheduleAfterHotelChanged(hotelId);
|
||||
openResourceNotifyService.notifyByHotelId(hotelId, SmHotelOpenResourceNotifyService.CHANGE_DETAIL);
|
||||
}
|
||||
return R.ok(ok);
|
||||
}
|
||||
@@ -1574,6 +1581,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
productMapper.updateById(product);
|
||||
}
|
||||
downstreamAutoPushService.scheduleAfterHotelChanged(hotelId);
|
||||
openResourceNotifyService.notifyByHotelId(hotelId, SmHotelOpenResourceNotifyService.CHANGE_DETAIL);
|
||||
return R.ok(productMapper.selectById(product.getId()));
|
||||
}
|
||||
|
||||
@@ -1592,6 +1600,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
|
||||
boolean ok = productMapper.deleteById(productId) > 0;
|
||||
if (ok) {
|
||||
downstreamAutoPushService.scheduleAfterHotelChanged(hotelId);
|
||||
openResourceNotifyService.notifyByHotelId(hotelId, SmHotelOpenResourceNotifyService.CHANGE_DETAIL);
|
||||
}
|
||||
return R.ok(ok);
|
||||
}
|
||||
|
||||
+386
-25
@@ -1,10 +1,13 @@
|
||||
package com.bsd.pjcloud.supermarket.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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.supermarket.config.SmHotelOpenProperties;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
|
||||
@@ -14,24 +17,47 @@ 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.mapper.SmHotelRoomMapper;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOpenApiService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
|
||||
import com.bsd.pjcloud.supermarket.util.HotelAreaAdcodeUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 下游统一开放 API:资源与下单均受「渠道部门映射」授权约束(按部门 / 按指定酒店)。
|
||||
* 伙伴编码优先 {@code partnerCode},空则回落 {@code appKey},须与映射表 channel 一致。
|
||||
* 图片字段对相对路径按 {@code sm-hotel.open.public-file-base-url} 拼成可外网访问的绝对地址。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
|
||||
private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
|
||||
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private static final Map<Integer, String> STAR_LABEL = Map.of(1, "民宿", 2, "经济", 3, "舒适", 4, "高档", 5, "五星");
|
||||
|
||||
private final SmHotelMapper hotelMapper;
|
||||
|
||||
private final SmHotelRoomMapper roomMapper;
|
||||
@@ -42,25 +68,45 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
|
||||
private final SmHotelOrderService hotelOrderService;
|
||||
|
||||
private final SmHotelChannelDeptService channelDeptService;
|
||||
|
||||
private final SmHotelOpenProperties openProperties;
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> hotelList(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
|
||||
R<List<Long>> scopeR = requireAllowedHotelIds(app);
|
||||
if (!scopeR.isOk()) {
|
||||
return R.failed(scopeR.getMsg());
|
||||
}
|
||||
List<Long> allowedIds = scopeR.getData();
|
||||
long current = toLong(body.get("current"), 1L);
|
||||
long size = Math.min(100L, toLong(body.get("size"), 20L));
|
||||
String keyword = StrUtil.trim(str(body.get("keyword")));
|
||||
String district = StrUtil.trim(str(body.get("district")));
|
||||
Page<SmHotelEntity> page = hotelMapper.selectPage(new Page<>(current, size),
|
||||
Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.eq(SmHotelEntity::getUpstreamStatus, "ACTIVE")
|
||||
.and(StrUtil.isNotBlank(keyword), w -> w.like(SmHotelEntity::getHotelName, keyword)
|
||||
.or()
|
||||
.like(SmHotelEntity::getHotelCode, keyword)
|
||||
.or()
|
||||
.like(SmHotelEntity::getAddress, keyword))
|
||||
.like(StrUtil.isNotBlank(district), SmHotelEntity::getDistrict, district)
|
||||
.orderByDesc(SmHotelEntity::getUpdateTime));
|
||||
Long districtAdcode = toLongObj(body.get("districtAdcode"));
|
||||
String bizArea = StrUtil.trim(str(body.get("bizArea")));
|
||||
LambdaQueryWrapper<SmHotelEntity> qw = Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.in(SmHotelEntity::getId, allowedIds)
|
||||
.eq(SmHotelEntity::getUpstreamStatus, "ACTIVE")
|
||||
.and(StrUtil.isNotBlank(keyword), w -> w.like(SmHotelEntity::getHotelName, keyword)
|
||||
.or()
|
||||
.like(SmHotelEntity::getHotelCode, keyword)
|
||||
.or()
|
||||
.like(SmHotelEntity::getAddress, keyword))
|
||||
.like(StrUtil.isNotBlank(bizArea), SmHotelEntity::getBizArea, bizArea)
|
||||
.orderByDesc(SmHotelEntity::getUpdateTime);
|
||||
HotelAreaAdcodeUtil.applyHotelListFilter(qw, districtAdcode, district);
|
||||
Page<SmHotelEntity> page = hotelMapper.selectPage(new Page<>(current, size), qw);
|
||||
List<SmHotelEntity> hotels = page.getRecords();
|
||||
List<Long> pageIds = hotels.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList();
|
||||
Map<Long, BigDecimal> minPriceMap = loadMinSellPrice(pageIds);
|
||||
List<Map<String, Object>> records = new ArrayList<>();
|
||||
for (SmHotelEntity h : page.getRecords()) {
|
||||
records.add(toHotelBrief(h));
|
||||
for (SmHotelEntity h : hotels) {
|
||||
Map<String, Object> row = toHotelBrief(h);
|
||||
if (h.getId() != null) {
|
||||
row.put("minSellPrice", minPriceMap.get(h.getId()));
|
||||
}
|
||||
records.add(row);
|
||||
}
|
||||
Map<String, Object> data = new LinkedHashMap<>(8);
|
||||
data.put("records", records);
|
||||
@@ -70,6 +116,107 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
return R.ok(data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> hotelFilterMeta(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
|
||||
R<List<Long>> scopeR = requireAllowedHotelIds(app);
|
||||
if (!scopeR.isOk()) {
|
||||
return R.failed(scopeR.getMsg());
|
||||
}
|
||||
List<Long> allowedIds = scopeR.getData();
|
||||
// 授权范围内轻量扫:仅取区域相关列
|
||||
List<SmHotelEntity> rows = hotelMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.select(SmHotelEntity::getDistrict, SmHotelEntity::getDistrictAdcode, SmHotelEntity::getBizArea)
|
||||
.in(SmHotelEntity::getId, allowedIds)
|
||||
.eq(SmHotelEntity::getUpstreamStatus, "ACTIVE"));
|
||||
Map<Long, String> areaByAdcode = new LinkedHashMap<>();
|
||||
Set<String> districtNames = new LinkedHashSet<>();
|
||||
Set<String> bizAreas = new LinkedHashSet<>();
|
||||
for (SmHotelEntity h : rows) {
|
||||
if (h == null) {
|
||||
continue;
|
||||
}
|
||||
String dName = cleanTextField(h.getDistrict());
|
||||
if (StrUtil.isNotBlank(dName)) {
|
||||
districtNames.add(dName);
|
||||
}
|
||||
if (h.getDistrictAdcode() != null) {
|
||||
areaByAdcode.putIfAbsent(h.getDistrictAdcode(), StrUtil.blankToDefault(dName, String.valueOf(h.getDistrictAdcode())));
|
||||
}
|
||||
String ba = cleanTextField(h.getBizArea());
|
||||
if (StrUtil.isNotBlank(ba)) {
|
||||
bizAreas.add(ba);
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> areas = areaByAdcode.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.map(e -> {
|
||||
Map<String, Object> one = new LinkedHashMap<>(4);
|
||||
one.put("districtAdcode", e.getKey());
|
||||
one.put("district", e.getValue());
|
||||
return one;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
List<Map<String, Object>> starLevels = STAR_LABEL.entrySet().stream()
|
||||
.sorted(Comparator.comparingInt(Map.Entry<Integer, String>::getKey).reversed())
|
||||
.map(e -> {
|
||||
Map<String, Object> one = new LinkedHashMap<>(2);
|
||||
one.put("value", e.getKey());
|
||||
one.put("label", e.getValue());
|
||||
return one;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
Map<String, Object> data = new LinkedHashMap<>(8);
|
||||
data.put("areas", areas);
|
||||
data.put("districts", new ArrayList<>(districtNames));
|
||||
data.put("bizAreas", new ArrayList<>(bizAreas));
|
||||
data.put("starLevels", starLevels);
|
||||
return R.ok(data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> hotelSync(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
|
||||
R<List<Long>> scopeR = requireAllowedHotelIds(app);
|
||||
if (!scopeR.isOk()) {
|
||||
return R.failed(scopeR.getMsg());
|
||||
}
|
||||
List<Long> allowedIds = scopeR.getData();
|
||||
long current = toLong(body.get("current"), 1L);
|
||||
long size = Math.min(100L, toLong(body.get("size"), 50L));
|
||||
LocalDateTime updatedSince = parseDateTime(str(body.get("updatedSince")));
|
||||
boolean includeOffline = Boolean.TRUE.equals(body.get("includeOffline"))
|
||||
|| "1".equals(str(body.get("includeOffline")))
|
||||
|| "true".equalsIgnoreCase(str(body.get("includeOffline")));
|
||||
LambdaQueryWrapper<SmHotelEntity> qw = Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.in(SmHotelEntity::getId, allowedIds)
|
||||
.ge(updatedSince != null, SmHotelEntity::getUpdateTime, updatedSince)
|
||||
.orderByAsc(SmHotelEntity::getUpdateTime)
|
||||
.orderByAsc(SmHotelEntity::getId);
|
||||
if (!includeOffline) {
|
||||
qw.eq(SmHotelEntity::getUpstreamStatus, "ACTIVE");
|
||||
}
|
||||
Page<SmHotelEntity> page = hotelMapper.selectPage(new Page<>(current, size), qw);
|
||||
List<Map<String, Object>> records = new ArrayList<>();
|
||||
for (SmHotelEntity h : page.getRecords()) {
|
||||
Map<String, Object> row = toHotelBrief(h);
|
||||
// 同步包:附带简介摘要标记,详情仍走 hotel/detail
|
||||
row.put("hasIntro", StrUtil.isNotBlank(h.getIntro()));
|
||||
records.add(row);
|
||||
}
|
||||
LocalDateTime maxUt = page.getRecords().stream()
|
||||
.map(SmHotelEntity::getUpdateTime)
|
||||
.filter(Objects::nonNull)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.orElse(updatedSince);
|
||||
Map<String, Object> data = new LinkedHashMap<>(8);
|
||||
data.put("records", records);
|
||||
data.put("total", page.getTotal());
|
||||
data.put("current", page.getCurrent());
|
||||
data.put("size", page.getSize());
|
||||
data.put("nextUpdatedSince", maxUt == null ? null : maxUt.format(DT));
|
||||
data.put("hasMore", page.getCurrent() * page.getSize() < page.getTotal());
|
||||
return R.ok(data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> hotelDetail(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
|
||||
String hotelCode = StrUtil.trim(str(body.get("hotelCode")));
|
||||
@@ -82,11 +229,16 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
if (hotel == null) {
|
||||
return R.failed("酒店不存在");
|
||||
}
|
||||
R<Void> auth = assertHotelInScope(app, hotel.getId());
|
||||
if (!auth.isOk()) {
|
||||
return R.failed(auth.getMsg());
|
||||
}
|
||||
Map<String, Object> data = toHotelBrief(hotel);
|
||||
data.put("coverImage", hotel.getCoverImage());
|
||||
data.put("carouselImages", hotel.getCarouselImages());
|
||||
data.put("intro", hotel.getIntro());
|
||||
// coverImage 已在 brief 中绝对化;轮播与简介内图片一并绝对化
|
||||
data.put("carouselImages", toPublicUrlCsv(hotel.getCarouselImages()));
|
||||
data.put("intro", toPublicHtml(hotel.getIntro()));
|
||||
data.put("starLevel", hotel.getStarLevel());
|
||||
data.put("facilityTags", hotel.getFacilityTags());
|
||||
List<SmHotelRoomEntity> rooms = roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
|
||||
.eq(SmHotelRoomEntity::getHotelId, hotel.getId())
|
||||
.orderByAsc(SmHotelRoomEntity::getRoomTypeCode));
|
||||
@@ -137,6 +289,10 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
if (hotel == null) {
|
||||
return R.failed("酒店不存在");
|
||||
}
|
||||
R<Void> auth = assertHotelInScope(app, hotel.getId());
|
||||
if (!auth.isOk()) {
|
||||
return R.failed(auth.getMsg());
|
||||
}
|
||||
List<SmHotelDailyEntity> dailies = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
|
||||
.eq(SmHotelDailyEntity::getHotelId, hotel.getId())
|
||||
.eq(StrUtil.isNotBlank(productCode), SmHotelDailyEntity::getProductCode, productCode)
|
||||
@@ -165,6 +321,10 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> orderCheck(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
|
||||
R<Void> auth = assertHotelCodeInScope(app, str(body.get("hotelCode")));
|
||||
if (!auth.isOk()) {
|
||||
return R.failed(auth.getMsg());
|
||||
}
|
||||
Map<String, Object> checkBody = new LinkedHashMap<>();
|
||||
checkBody.put("extHotelId", body.get("hotelCode"));
|
||||
checkBody.put("extProductId", body.get("productCode"));
|
||||
@@ -173,20 +333,33 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
checkBody.put("checkOut", body.get("checkOutDate"));
|
||||
checkBody.put("roomCnt", body.get("count") != null ? body.get("count") : body.get("quantity"));
|
||||
checkBody.put("price", body.get("totalAmount") != null ? body.get("totalAmount") : body.get("totalPrice"));
|
||||
return hotelOrderService.checkBookableForDownstream(checkBody);
|
||||
R<Map<String, Object>> r = hotelOrderService.checkBookableForDownstream(checkBody);
|
||||
if (r == null || !r.isOk()) {
|
||||
return r == null ? R.failed("可订校验失败") : r;
|
||||
}
|
||||
Map<String, Object> data = r.getData();
|
||||
if (data != null && Boolean.FALSE.equals(data.get("bookable"))) {
|
||||
Object reasons = data.get("reasons");
|
||||
String msg = reasons instanceof java.util.Collection && !((java.util.Collection<?>) reasons).isEmpty()
|
||||
? StrUtil.join(";", (java.util.Collection<?>) reasons)
|
||||
: "当前不可订";
|
||||
return R.failed(msg);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<JSONObject> orderCreate(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
|
||||
// 保证订单来源写入伙伴编码
|
||||
R<JSONObject> r = hotelOrderService.createFromDownstream(app.getAppKey(), body);
|
||||
return r;
|
||||
R<Void> auth = assertHotelCodeInScope(app, str(body.get("hotelCode")));
|
||||
if (!auth.isOk()) {
|
||||
return R.failed(auth.getMsg());
|
||||
}
|
||||
return hotelOrderService.createFromDownstream(app.getAppKey(), body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<JSONObject> orderCancel(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
|
||||
R<JSONObject> r = hotelOrderService.cancelFromDownstream(app.getAppKey(), body);
|
||||
return r;
|
||||
return hotelOrderService.cancelFromDownstream(app.getAppKey(), body);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -194,21 +367,176 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
return hotelOrderService.queryFromDownstream(app.getAppKey(), body);
|
||||
}
|
||||
|
||||
/** 开放应用 → 渠道映射用的伙伴编码 */
|
||||
static String resolvePartnerCode(SmHotelDownstreamAppEntity app) {
|
||||
if (app == null) {
|
||||
return "";
|
||||
}
|
||||
return StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()), StrUtil.trim(app.getAppKey()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析授权酒店 ID;未配置任何启用映射时拒绝(避免开放应用默认可看全库)。
|
||||
*/
|
||||
private R<List<Long>> requireAllowedHotelIds(SmHotelDownstreamAppEntity app) {
|
||||
String partner = resolvePartnerCode(app);
|
||||
if (StrUtil.isBlank(partner)) {
|
||||
return R.failed("开放应用未配置伙伴编码");
|
||||
}
|
||||
List<Long> ids = channelDeptService.listHotelIdsForDownstreamPartner(partner);
|
||||
if (CollUtil.isEmpty(ids)) {
|
||||
return R.failed("未配置授权酒店范围,请先在「渠道部门映射」为伙伴「" + partner
|
||||
+ "」配置下游授权(按部门或指定酒店)并启用");
|
||||
}
|
||||
return R.ok(ids);
|
||||
}
|
||||
|
||||
private R<Void> assertHotelInScope(SmHotelDownstreamAppEntity app, Long hotelId) {
|
||||
if (hotelId == null) {
|
||||
return R.failed("酒店不存在");
|
||||
}
|
||||
R<List<Long>> scopeR = requireAllowedHotelIds(app);
|
||||
if (!scopeR.isOk()) {
|
||||
return R.failed(scopeR.getMsg());
|
||||
}
|
||||
Set<Long> allowed = new HashSet<>(scopeR.getData());
|
||||
if (!allowed.contains(hotelId)) {
|
||||
return R.failed("该酒店不在下游授权范围内");
|
||||
}
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
private R<Void> assertHotelCodeInScope(SmHotelDownstreamAppEntity app, String hotelCode) {
|
||||
if (StrUtil.isBlank(hotelCode)) {
|
||||
return R.failed("hotelCode 不能为空");
|
||||
}
|
||||
SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
|
||||
.select(SmHotelEntity::getId)
|
||||
.eq(SmHotelEntity::getHotelCode, StrUtil.trim(hotelCode))
|
||||
.last("limit 1"));
|
||||
if (hotel == null || hotel.getId() == null) {
|
||||
return R.failed("酒店不存在");
|
||||
}
|
||||
return assertHotelInScope(app, hotel.getId());
|
||||
}
|
||||
|
||||
private Map<String, Object> toHotelBrief(SmHotelEntity h) {
|
||||
Map<String, Object> row = new LinkedHashMap<>(12);
|
||||
Map<String, Object> row = new LinkedHashMap<>(18);
|
||||
row.put("hotelCode", h.getHotelCode());
|
||||
row.put("hotelName", h.getHotelName());
|
||||
row.put("groupCode", h.getGroupCode());
|
||||
row.put("address", h.getAddress());
|
||||
row.put("district", h.getDistrict());
|
||||
row.put("bizArea", h.getBizArea());
|
||||
row.put("coverImage", toPublicUrl(h.getCoverImage()));
|
||||
row.put("address", cleanTextField(h.getAddress()));
|
||||
row.put("district", cleanTextField(h.getDistrict()));
|
||||
row.put("districtAdcode", h.getDistrictAdcode());
|
||||
row.put("bizArea", cleanTextField(h.getBizArea()));
|
||||
row.put("starLevel", h.getStarLevel());
|
||||
row.put("score", h.getScore());
|
||||
row.put("facilityTags", cleanTextField(h.getFacilityTags()));
|
||||
row.put("longitude", h.getLongitude());
|
||||
row.put("latitude", h.getLatitude());
|
||||
row.put("phone", cleanTextField(h.getPhone()));
|
||||
row.put("dataSource", h.getSheetName());
|
||||
row.put("status", h.getUpstreamStatus());
|
||||
row.put("updateTime", h.getUpdateTime() == null ? null : h.getUpdateTime().format(DT));
|
||||
return row;
|
||||
}
|
||||
|
||||
private Map<Long, BigDecimal> loadMinSellPrice(List<Long> hotelIds) {
|
||||
Map<Long, BigDecimal> map = new HashMap<>();
|
||||
if (CollUtil.isEmpty(hotelIds)) {
|
||||
return map;
|
||||
}
|
||||
List<Map<String, Object>> rows = productMapper.selectMinSellPriceByHotelIds(hotelIds);
|
||||
if (rows == null) {
|
||||
return map;
|
||||
}
|
||||
for (Map<String, Object> row : rows) {
|
||||
if (row == null || row.get("hotelId") == null || row.get("minSell") == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
map.put(Long.valueOf(String.valueOf(row.get("hotelId"))),
|
||||
new BigDecimal(String.valueOf(row.get("minSell"))));
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
// skip bad row
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 过滤误存的 [] / {} / () 等占位串,避免下游直接展示 */
|
||||
private static String cleanTextField(String v) {
|
||||
if (StrUtil.isBlank(v)) {
|
||||
return null;
|
||||
}
|
||||
String s = v.trim();
|
||||
if ("[]".equals(s) || "{}".equals(s) || "()".equals(s) || "()".equals(s)
|
||||
|| "null".equalsIgnoreCase(s)) {
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 相对路径拼公网根地址;已是 http(s) 则原样返回。
|
||||
*/
|
||||
private String toPublicUrl(String path) {
|
||||
if (StrUtil.isBlank(path)) {
|
||||
return path;
|
||||
}
|
||||
String p = path.trim();
|
||||
if (StrUtil.startWithIgnoreCase(p, "http://") || StrUtil.startWithIgnoreCase(p, "https://")
|
||||
|| StrUtil.startWithIgnoreCase(p, "data:")) {
|
||||
return p;
|
||||
}
|
||||
String base = StrUtil.trim(openProperties.getPublicFileBaseUrl());
|
||||
if (StrUtil.isBlank(base)) {
|
||||
return p;
|
||||
}
|
||||
while (base.endsWith("/")) {
|
||||
base = base.substring(0, base.length() - 1);
|
||||
}
|
||||
// 库内偶发带 /api 前缀时,与 base 末尾 /api 去重
|
||||
if (base.endsWith("/api") && (p.startsWith("/api/") || "/api".equals(p))) {
|
||||
p = p.substring("/api".length());
|
||||
}
|
||||
if (!p.startsWith("/")) {
|
||||
p = "/" + p;
|
||||
}
|
||||
return base + p;
|
||||
}
|
||||
|
||||
/** 逗号分隔多图,逐个绝对化 */
|
||||
private String toPublicUrlCsv(String csv) {
|
||||
if (StrUtil.isBlank(csv)) {
|
||||
return csv;
|
||||
}
|
||||
return Arrays.stream(csv.split(","))
|
||||
.map(String::trim)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.map(this::toPublicUrl)
|
||||
.collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
private static final Pattern HTML_URL_ATTR = Pattern.compile("(?i)(\\b(?:src|href)=[\"'])([^\"']+)([\"'])");
|
||||
|
||||
/** 富文本内 img/src、href 相对路径绝对化,便于下游小程序直接渲染 */
|
||||
private String toPublicHtml(String html) {
|
||||
if (StrUtil.isBlank(html)) {
|
||||
return html;
|
||||
}
|
||||
Matcher m = HTML_URL_ATTR.matcher(html);
|
||||
StringBuffer sb = new StringBuffer();
|
||||
while (m.find()) {
|
||||
String abs = toPublicUrl(m.group(2));
|
||||
m.appendReplacement(sb, Matcher.quoteReplacement(m.group(1) + abs + m.group(3)));
|
||||
}
|
||||
m.appendTail(sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
@@ -225,6 +553,39 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private static Long toLongObj(Object o) {
|
||||
if (o == null || StrUtil.isBlank(String.valueOf(o))) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(o).trim());
|
||||
}
|
||||
catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime parseDateTime(String s) {
|
||||
if (StrUtil.isBlank(s)) {
|
||||
return null;
|
||||
}
|
||||
String t = s.trim();
|
||||
try {
|
||||
if (t.length() == 10) {
|
||||
return LocalDate.parse(t, DAY).atStartOfDay();
|
||||
}
|
||||
return LocalDateTime.parse(t, DT);
|
||||
}
|
||||
catch (Exception e) {
|
||||
try {
|
||||
return LocalDateTime.parse(t.replace('T', ' ').substring(0, Math.min(19, t.length())), DT);
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate parseDay(String s) {
|
||||
if (StrUtil.isBlank(s)) {
|
||||
return null;
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.bsd.pjcloud.supermarket.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
|
||||
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
|
||||
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamOpenCallbackClient;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper;
|
||||
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 启用回调的开放应用 ∩ 渠道部门映射授权 → 异步推送资源变更。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SmHotelOpenResourceNotifyServiceImpl implements SmHotelOpenResourceNotifyService {
|
||||
|
||||
public static final String CHANGE_UPSERT = "HOTEL_UPSERT";
|
||||
|
||||
public static final String CHANGE_OFFLINE = "HOTEL_OFFLINE";
|
||||
|
||||
public static final String CHANGE_DELETE = "HOTEL_DELETE";
|
||||
|
||||
public static final String CHANGE_DETAIL = "HOTEL_DETAIL";
|
||||
|
||||
private final SmHotelMapper hotelMapper;
|
||||
|
||||
private final SmHotelDownstreamAppMapper appMapper;
|
||||
|
||||
private final SmHotelChannelDeptService channelDeptService;
|
||||
|
||||
private final DownstreamOpenCallbackClient callbackClient;
|
||||
|
||||
@Override
|
||||
public void notifyByHotelId(Long hotelId, String changeType) {
|
||||
if (hotelId == null) {
|
||||
return;
|
||||
}
|
||||
SmHotelEntity hotel = hotelMapper.selectById(hotelId);
|
||||
if (hotel == null || StrUtil.isBlank(hotel.getHotelCode())) {
|
||||
return;
|
||||
}
|
||||
String type = StrUtil.blankToDefault(changeType, CHANGE_UPSERT);
|
||||
if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus()) && CHANGE_UPSERT.equals(type)) {
|
||||
type = CHANGE_OFFLINE;
|
||||
}
|
||||
notifyByHotelCode(hotel.getHotelCode(), hotelId, type);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyByHotelCode(String hotelCode, Long hotelId, String changeType) {
|
||||
if (StrUtil.isBlank(hotelCode)) {
|
||||
return;
|
||||
}
|
||||
List<SmHotelDownstreamAppEntity> apps = appMapper.selectList(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
|
||||
.eq(SmHotelDownstreamAppEntity::getStatus, "1")
|
||||
.eq(SmHotelDownstreamAppEntity::getCallbackEnabled, "1"));
|
||||
if (CollUtil.isEmpty(apps)) {
|
||||
return;
|
||||
}
|
||||
String type = StrUtil.blankToDefault(changeType, CHANGE_UPSERT);
|
||||
Map<String, Object> payload = new LinkedHashMap<>(4);
|
||||
payload.put("hotelCode", hotelCode.trim());
|
||||
payload.put("changeType", type);
|
||||
int hit = 0;
|
||||
for (SmHotelDownstreamAppEntity app : apps) {
|
||||
if (app == null || StrUtil.hasBlank(app.getCallbackBaseUrl(), app.getCallbackResourcePath())) {
|
||||
continue;
|
||||
}
|
||||
String partner = StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()), StrUtil.trim(app.getAppKey()));
|
||||
if (StrUtil.isBlank(partner)) {
|
||||
continue;
|
||||
}
|
||||
if (hotelId != null && !inScope(partner, hotelId)) {
|
||||
continue;
|
||||
}
|
||||
callbackClient.notifyResourceChangeAsync(app.getId(), hotelCode.trim(), type, payload);
|
||||
hit++;
|
||||
}
|
||||
if (hit > 0) {
|
||||
log.info("|开放资源回调|hotelCode={}|changeType={}|apps={}|", hotelCode, type, hit);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean inScope(String partner, Long hotelId) {
|
||||
try {
|
||||
List<Long> ids = channelDeptService.listHotelIdsForDownstreamPartner(partner);
|
||||
if (CollUtil.isEmpty(ids)) {
|
||||
return false;
|
||||
}
|
||||
Set<Long> set = new HashSet<>(ids);
|
||||
return set.contains(hotelId);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.warn("|开放资源回调|范围解析失败|partner={}|{}", partner, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -1,5 +1,6 @@
|
||||
package com.bsd.pjcloud.supermarket.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
@@ -19,6 +20,7 @@ import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrder
|
||||
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouBookStatus;
|
||||
import com.bsd.pjcloud.supermarket.mapper.*;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOrderUpstreamLogService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
|
||||
@@ -98,6 +100,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
|
||||
private final SmHotelChannelConfigService channelConfigService;
|
||||
|
||||
private final SmHotelChannelDeptService channelDeptService;
|
||||
|
||||
private final RemoterThirdAppPlusPayService remoterThirdAppPlusPayService;
|
||||
|
||||
private final DownstreamOpenCallbackClient openCallbackClient;
|
||||
@@ -574,6 +578,15 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) {
|
||||
return R.failed("酒店已下架,无法下单");
|
||||
}
|
||||
// 开放应用下单:二次校验渠道部门映射授权(与 OpenApi 列表/详情同口径)
|
||||
String partnerForScope = StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()),
|
||||
StrUtil.blankToDefault(app.getAppKey(), ""));
|
||||
if (StrUtil.isNotBlank(partnerForScope) && channelDeptService != null) {
|
||||
List<Long> allowed = channelDeptService.listHotelIdsForDownstreamPartner(partnerForScope);
|
||||
if (CollUtil.isEmpty(allowed) || !allowed.contains(hotel.getId())) {
|
||||
return R.failed("该酒店不在下游授权范围内,请先在「渠道部门映射」配置");
|
||||
}
|
||||
}
|
||||
SmHotelProductEntity product = null;
|
||||
if (StrUtil.isNotBlank(productCode)) {
|
||||
product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
|
||||
@@ -2526,8 +2539,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
* 上游姓名格式:LastName=姓 / FirstName=名。
|
||||
* <ul>
|
||||
* <li>中文:首字为姓,其余为名(复姓未特殊处理;单字名时名为 "/")</li>
|
||||
* <li>英文空格分隔:按 First Last 或 Last First 取两段</li>
|
||||
* <li>其它:整段进 LastName,FirstName="/"</li>
|
||||
* <li>英文空格分隔:取末段为姓、首段为名</li>
|
||||
* <li>英文/拼音单段:姓、名都用整段(勿填 "/",商游 order/add 易直接 500)</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static NameParts splitPersonName(String raw) {
|
||||
@@ -2548,7 +2561,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
|
||||
if (parts.length >= 2) {
|
||||
return new NameParts(parts[parts.length - 1], parts[0]);
|
||||
}
|
||||
return new NameParts(name, "/");
|
||||
// 拼音/英文单段:避免 firstName="/" 被上游拒
|
||||
return new NameParts(name, name);
|
||||
}
|
||||
|
||||
private record NameParts(String lastName, String firstName) {
|
||||
|
||||
+25
-2
@@ -21,6 +21,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.service.SmHotelDownstreamAutoPushService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService;
|
||||
import com.bsd.pjcloud.supermarket.service.SmHotelService;
|
||||
import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO;
|
||||
import com.bsd.pjcloud.common.core.constant.CommonConstants;
|
||||
@@ -65,12 +66,16 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
|
||||
private final SmHotelDownstreamAutoPushService downstreamAutoPushService;
|
||||
|
||||
private final SmHotelOpenResourceNotifyService openResourceNotifyService;
|
||||
|
||||
public SmHotelServiceImpl(SmHotelRoomMapper roomMapper, SmHotelProductMapper productMapper,
|
||||
SmHotelDailyMapper dailyMapper, @Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService) {
|
||||
SmHotelDailyMapper dailyMapper, @Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService,
|
||||
@Lazy SmHotelOpenResourceNotifyService openResourceNotifyService) {
|
||||
this.roomMapper = roomMapper;
|
||||
this.productMapper = productMapper;
|
||||
this.dailyMapper = dailyMapper;
|
||||
this.downstreamAutoPushService = downstreamAutoPushService;
|
||||
this.openResourceNotifyService = openResourceNotifyService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -146,6 +151,8 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
entity.setDeptId(SecurityUtils.getUser().getDeptId());
|
||||
}
|
||||
baseMapper.insert(entity);
|
||||
// 开放下游:资料新增回调(授权范围内的开放应用)
|
||||
openResourceNotifyService.notifyByHotelId(entity.getId(), SmHotelOpenResourceNotifyService.CHANGE_UPSERT);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -200,6 +207,11 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
if (ok) {
|
||||
// 上架状态/资料变更后防抖推下游(下架走关房推)
|
||||
downstreamAutoPushService.scheduleAfterHotelChanged(entity.getId());
|
||||
// 开放下游:资料变更回调
|
||||
String ct = "OFFLINE".equalsIgnoreCase(entity.getUpstreamStatus())
|
||||
? SmHotelOpenResourceNotifyService.CHANGE_OFFLINE
|
||||
: SmHotelOpenResourceNotifyService.CHANGE_UPSERT;
|
||||
openResourceNotifyService.notifyByHotelId(entity.getId(), ct);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
@@ -210,7 +222,18 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
|
||||
if (ids == null || ids.length == 0) {
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
return removeByIds(CollUtil.toList(ids));
|
||||
List<Long> idList = CollUtil.toList(ids);
|
||||
List<SmHotelEntity> before = baseMapper.selectBatchIds(idList);
|
||||
boolean ok = removeByIds(idList);
|
||||
if (ok && CollUtil.isNotEmpty(before)) {
|
||||
for (SmHotelEntity h : before) {
|
||||
if (h != null && StrUtil.isNotBlank(h.getHotelCode())) {
|
||||
openResourceNotifyService.notifyByHotelCode(h.getHotelCode(), h.getId(),
|
||||
SmHotelOpenResourceNotifyService.CHANGE_DELETE);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -110,6 +110,13 @@ spring:
|
||||
# # 同步后:已停用但上游又出现 → 自动恢复 ACTIVE
|
||||
# auto-reactivate-online-hotels: true
|
||||
#
|
||||
## ========== 下游开放平台(/third/party/open)==========
|
||||
## 图片公网根地址:相对路径(如 /admin/sys-file/...)会拼此前缀后下发给下游
|
||||
sm-hotel:
|
||||
open:
|
||||
# public-file-base-url: https://www.zianxin.com/api
|
||||
public-file-base-url: http://127.0.0.1:9999
|
||||
|
||||
## ========== 下游销售方推送(环启等;优先走库表「渠道对接配置」direction=downstream)==========
|
||||
## 本系统作为 A,主动 POST 到 B 的 /third/party/api/*;可配置多家;部门范围见「渠道部门映射」
|
||||
## 下列 YAML 仅作无库表时的单下游兼容回落(partner=default)
|
||||
|
||||
Reference in new issue
Block a user