This commit is contained in:
zjf committed 2026-10-21 13:14:48 +08:00
1 parent a74dec5e7a
commit 04a39fbeff
14 files changed
+910 -101

No files matched your search

@@ -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";
@@ -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 = "";
}
@@ -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}
)
@@ -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")
@@ -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) {
@@ -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 &gt;= #{fromDate}
AND stay_date &lt; #{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);
/**
@@ -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);
@@ -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);
}
@@ -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);
}
@@ -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;
@@ -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;
}
}
}
@@ -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) {
@@ -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)