diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java index b8069a8d..b19c37a8 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java @@ -78,9 +78,26 @@ public class DailyMenuAdminVO { /** 剩余总库存(与 remainStock 同义,兼容字段) */ private Integer remainOfflineStock; - /** 剩余总库存 */ + /** 剩余总库存(套餐可能已被组成菜可拼份数收紧) */ private Integer remainStock; + /** + * 本行自身剩余:总库存 − 移动端已订 − 消费机已售(套餐取组成菜 min 之前)。 + * 单品与 remainStock 同值;套餐用于对照「为何剩余被压小」。 + */ + private Integer selfRemainStock; + + /** + * 套餐:同日同餐段组成菜可拼出的套餐份数;非套餐或无法计算时为空。 + */ + private Integer comboFormableRemain; + + /** 套餐剩余是否被组成菜可拼份数卡住(remainStock < selfRemainStock) */ + private Boolean remainLimitedByCombo; + + /** 剩余口径说明,如「本行585,受组成菜限制为28」;未受限可为空 */ + private String remainStockHint; + private Integer price; private Integer spuType; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuStockSupport.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuStockSupport.java index 5588b7e8..71349bf8 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuStockSupport.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuStockSupport.java @@ -179,6 +179,11 @@ public class CanteenDailyMenuStockSupport { row.setRemainOfflineStock(shared); row.setStock(shared); row.setRemainStock(shared); + // 本行自身剩余(套餐联动前),便于管理端解释「剩余为何小于总库存−已订」 + row.setSelfRemainStock(shared); + row.setComboFormableRemain(null); + row.setRemainLimitedByCombo(Boolean.FALSE); + row.setRemainStockHint(null); if (row.getSpuId() != null) { byMealSpu.put(mealSpuKey(row.getHourType(), row.getSpuId()), row); } @@ -193,13 +198,32 @@ public class CanteenDailyMenuStockSupport { row.setRemainOnlineStock(Math.min(row.getRemainOnlineStock(), formableOnline)); } if (formableOffline >= 0) { + row.setComboFormableRemain(formableOffline); row.setRemainOfflineStock(Math.min(row.getRemainOfflineStock(), formableOffline)); } row.setStock(row.getRemainOfflineStock()); row.setRemainStock(row.getRemainOfflineStock()); + applyComboRemainHint(row); } } + /** 套餐剩余被组成菜卡住时写提示文案,列表/详情直接展示 */ + private static void applyComboRemainHint(DailyMenuAdminVO row) { + if (row == null) { + return; + } + int self = nullToZero(row.getSelfRemainStock()); + int effective = nullToZero(row.getRemainStock()); + Integer formable = row.getComboFormableRemain(); + boolean limited = formable != null && effective < self; + row.setRemainLimitedByCombo(limited); + if (!limited) { + row.setRemainStockHint(null); + return; + } + row.setRemainStockHint("本行剩余 " + self + ",受组成菜可拼限制为 " + effective); + } + private static void applyAppQuotasAndRemains(DailyMenuAppVO row, boolean stockAsOnline) { int total = totalStockOf(row.getOfflineStock()); int mobile = quotaOrDefault(row.getOnlineStock()); diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelOpenApi.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelOpenApi.java new file mode 100644 index 00000000..3c347363 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelOpenApi.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.supermarket.constant; + +/** + * 酒店下游统一开放 API 路径与限流接口码(对方按我方规范对接)。 + */ +public final class SmHotelOpenApi { + + public static final String BASE = "/third/party/open"; + + public static final String HOTEL_LIST = BASE + "/hotel/list"; + + public static final String HOTEL_DETAIL = BASE + "/hotel/detail"; + + public static final String HOTEL_DAILY = BASE + "/hotel/daily"; + + public static final String ORDER_CHECK = BASE + "/order/check"; + + public static final String ORDER_CREATE = BASE + "/order/create"; + + public static final String ORDER_CANCEL = BASE + "/order/cancel"; + + public static final String ORDER_QUERY = BASE + "/order/query"; + + /** 限流接口码 */ + public static final String API_HOTEL_LIST = "hotel.list"; + + public static final String API_HOTEL_DETAIL = "hotel.detail"; + + public static final String API_HOTEL_DAILY = "hotel.daily"; + + public static final String API_ORDER_CHECK = "order.check"; + + public static final String API_ORDER_CREATE = "order.create"; + + public static final String API_ORDER_CANCEL = "order.cancel"; + + public static final String API_ORDER_QUERY = "order.query"; + + /** 默认每秒 QPS(可被应用 rate_limit_json 覆盖) */ + public static final int DEFAULT_QPS = 20; + + private SmHotelOpenApi() { + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamAppEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamAppEntity.java index 32999848..cb99a5ca 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamAppEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamAppEntity.java @@ -13,7 +13,7 @@ import java.time.LocalDateTime; @TenantTable @TableName("canteen_hotel_downstream_app") @EqualsAndHashCode(callSuper = true) -@Schema(description = "下游销售应用") +@Schema(description = "下游开放应用(统一开放平台凭证)") public class SmHotelDownstreamAppEntity extends Model { @TableId(type = IdType.ASSIGN_ID) @@ -21,12 +21,33 @@ public class SmHotelDownstreamAppEntity extends Model { private Long downstreamAppId; - @Schema(description = "下游伙伴编码(环启等)") + @Schema(description = "订单来源编码(开放应用/环启/慧订等)") private String partnerCode; private Long hotelId; @@ -134,11 +134,26 @@ public class SmHotelOrderEntity extends Model { @Schema(description = "产品名称") private String productName; - /** 列表展示:下单来源 APP/ADMIN/DOWNSTREAM(非表字段,由 channelNo 推断) */ + /** 列表展示:下单来源 APP/ADMIN/LOCAL/DOWNSTREAM(非表字段) */ @TableField(exist = false) @Schema(description = "下单来源") private String orderSource; + /** 列表展示:订单来源文案,如 App / 后台 / 环启·huanqi / 慧订·huiding / 应用名(非表字段) */ + @TableField(exist = false) + @Schema(description = "订单来源展示名") + private String orderSourceLabel; + + /** 列表展示:下游协议 huanqi/huiding(非表字段,由 partnerCode 解析) */ + @TableField(exist = false) + @Schema(description = "下游协议") + private String partnerProtocol; + + /** 列表展示:下游应用名称(非表字段,由 downstreamAppId 解析) */ + @TableField(exist = false) + @Schema(description = "下游应用名称") + private String downstreamAppName; + /** 列表展示:酒店数据来源 local/shangyou/其它渠道(非表字段) */ @TableField(exist = false) @Schema(description = "酒店数据来源") diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelUserSearchPrefEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelUserSearchPrefEntity.java new file mode 100644 index 00000000..99063e04 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelUserSearchPrefEntity.java @@ -0,0 +1,67 @@ +package com.bsd.pjcloud.supermarket.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TenantTable +@TableName("canteen_hotel_user_search_pref") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "移动端酒店用户搜索习惯") +public class SmHotelUserSearchPrefEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + private Long userId; + + @Schema(description = "间数") + private Integer rooms; + + @Schema(description = "成人数") + private Integer adults; + + @Schema(description = "儿童数") + private Integer children; + + @Schema(description = "偏好连住晚数") + private Integer nights; + + private String city; + + private String keyword; + + private LocalDate lastCheckIn; + + private LocalDate lastCheckOut; + + @TableField(fill = FieldFill.INSERT) + private String createBy; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + @TableField(fill = FieldFill.INSERT) + private String delFlag; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java index fd158809..9774ec8d 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java @@ -14,6 +14,10 @@ import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelUserSearchPrefEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelRoomMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelUserSearchPrefMapper; import com.bsd.pjcloud.supermarket.service.SmHotelMidService; import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; import com.bsd.pjcloud.supermarket.service.SmHotelService; @@ -26,6 +30,9 @@ import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -53,12 +60,23 @@ public class SmHotelAppController { private static final List DEFAULT_FACILITY_OPTIONS = List.of("含早餐", "免费取消", "免费停车", "立即确认", "限时优惠", "接站服务"); + private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE; + + /** 候选酒店 id 用 IN 时的上限;过大则回退 EXISTS,避免超长 SQL */ + private static final int MAX_IN_HOTEL_IDS = 8000; + private final SmHotelService hotelService; private final SmHotelMidService midService; private final SmHotelOrderService orderService; + private final SmHotelUserSearchPrefMapper userSearchPrefMapper; + + private final SmHotelDailyMapper dailyMapper; + + private final SmHotelRoomMapper roomMapper; + @Operation(summary = "列表筛选项(区域/星级/设施)") @GetMapping("/filter-meta") public R> filterMeta() { @@ -114,7 +132,7 @@ public class SmHotelAppController { return R.ok(out); } - @Operation(summary = "上架酒店分页(关键字/区域/星级/评分/价格/设施)") + @Operation(summary = "上架酒店分页(关键字/区域/星级/评分/价格/设施/人数/入住区间)") @GetMapping("/page") public R>> page(@ParameterObject Page page, @RequestParam(required = false) String keyword, @@ -126,7 +144,13 @@ public class SmHotelAppController { @RequestParam(required = false) BigDecimal minScore, @RequestParam(required = false) BigDecimal minPrice, @RequestParam(required = false) BigDecimal maxPrice, - @RequestParam(required = false) String facilityTags) { + @RequestParam(required = false) String facilityTags, + @RequestParam(required = false) Integer rooms, + @RequestParam(required = false) Integer adults, + @RequestParam(required = false) Integer children, + @RequestParam(required = false) Integer guests, + @RequestParam(required = false) String checkIn, + @RequestParam(required = false) String checkOut) { List starList = parseIntCsv(starLevels); List facilityList = parseStrCsv(facilityTags); var wrapper = Wrappers.lambdaQuery() @@ -159,6 +183,26 @@ public class SmHotelAppController { 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 candidateIds = resolveAppListCandidateHotelIds(stayFrom, stayToExclusive, needCap); + if (candidateIds != null) { + if (candidateIds.isEmpty()) { + Page> empty = new Page<>(page.getCurrent(), page.getSize(), 0); + empty.setRecords(List.of()); + return R.ok(empty); + } + wrapper.in(SmHotelEntity::getId, candidateIds); + } + else { + // 候选过多:回退轻量 EXISTS(不再 join product) + applyBookableHotelFilter(wrapper, stayFrom, stayToExclusive); + applyGuestCapacityFilter(wrapper, needCap); + } // 起价区间:按产品最低卖价过滤(分页前约束) if (minPrice != null || maxPrice != null) { List priceHitIds = loadHotelIdsByMinPriceRange(minPrice, maxPrice); @@ -181,6 +225,70 @@ public class SmHotelAppController { return R.ok(out); } + @Operation(summary = "读取当前用户酒店搜索习惯") + @GetMapping("/search-pref") + public R> getSearchPref() { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return R.failed("请先登录"); + } + SmHotelUserSearchPrefEntity row = userSearchPrefMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelUserSearchPrefEntity::getUserId, user.getId()) + .last("limit 1")); + if (row == null) { + return R.ok(defaultSearchPref()); + } + return R.ok(toSearchPrefMap(row)); + } + + @Operation(summary = "保存当前用户酒店搜索习惯") + @PostMapping("/search-pref") + public R> saveSearchPref(@RequestBody Map body) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return R.failed("请先登录"); + } + if (body == null) { + body = Map.of(); + } + int rooms = clampInt(body.get("rooms"), 1, 10, 1); + int adults = clampInt(body.get("adults"), 1, 20, 1); + int children = clampInt(body.get("children"), 0, 20, 0); + int nights = clampInt(body.get("nights"), 1, 30, 1); + LocalDate checkIn = parseDay(str(body.get("checkIn"))); + LocalDate checkOut = parseDay(str(body.get("checkOut"))); + if (checkIn != null && checkOut != null && checkOut.isAfter(checkIn)) { + nights = (int) Math.min(30, Math.max(1, ChronoUnit.DAYS.between(checkIn, checkOut))); + } + SmHotelUserSearchPrefEntity row = userSearchPrefMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelUserSearchPrefEntity::getUserId, user.getId()) + .last("limit 1")); + LocalDateTime now = LocalDateTime.now(); + if (row == null) { + row = new SmHotelUserSearchPrefEntity(); + row.setUserId(user.getId()); + row.setCreateBy(String.valueOf(user.getId())); + row.setCreateTime(now); + } + row.setRooms(rooms); + row.setAdults(adults); + row.setChildren(children); + row.setNights(nights); + row.setCity(StrUtil.maxLength(StrUtil.trim(str(body.get("city"))), 64)); + row.setKeyword(StrUtil.maxLength(StrUtil.trim(str(body.get("keyword"))), 128)); + row.setLastCheckIn(checkIn); + row.setLastCheckOut(checkOut); + row.setUpdateBy(String.valueOf(user.getId())); + row.setUpdateTime(now); + if (row.getId() == null) { + userSearchPrefMapper.insert(row); + } + else { + userSearchPrefMapper.updateById(row); + } + return R.ok(toSearchPrefMap(row)); + } + @Operation(summary = "酒店详情(含房型/产品)") @GetMapping("/detail/{id}") public R detail(@PathVariable Long id) { @@ -506,6 +614,146 @@ public class SmHotelAppController { return map; } + /** + * 解析列表候选酒店: + * 1) 入住区间内有日历; + * 2) 可选:房型可住人数达标。 + * 返回 null 表示候选过多,调用方回退 EXISTS。 + */ + private List resolveAppListCandidateHotelIds(LocalDate stayFrom, LocalDate stayToExclusive, int needCapPerRoom) { + LocalDate from = stayFrom != null ? stayFrom : LocalDate.now(); + LocalDate toEx = stayToExclusive; + if (toEx == null || !toEx.isAfter(from)) { + // 未传离店:只扫未来 30 天有日历的酒店,避免无上界扫全表 + toEx = from.plusDays(30); + } + List bookable = dailyMapper.selectDistinctHotelIdsByStayRange(from, toEx); + if (bookable == null || bookable.isEmpty()) { + return List.of(); + } + List ids = bookable.stream().filter(Objects::nonNull).distinct().toList(); + if (needCapPerRoom > 1) { + List capHotels = roomMapper.selectDistinctHotelIdsByMinCapacity(needCapPerRoom); + if (capHotels == null || capHotels.isEmpty()) { + return List.of(); + } + Set capSet = new java.util.HashSet<>(capHotels); + ids = ids.stream().filter(capSet::contains).toList(); + if (ids.isEmpty()) { + return List.of(); + } + } + if (ids.size() > MAX_IN_HOTEL_IDS) { + return null; + } + return ids; + } + + /** 轻量 EXISTS:仅日历表,不再 join product */ + private void applyBookableHotelFilter(LambdaQueryWrapper 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); + } + wrapper.apply(""" + EXISTS ( + SELECT 1 FROM canteen_hotel_daily d + WHERE d.hotel_id = canteen_hotel.id + AND d.del_flag = '0' + AND d.stay_date >= {0} + AND d.stay_date < {1} + ) + """, from, toEx); + } + + /** + * 按房型可住人数筛选:至少有一个房型 capacity >= 每间所需人数; + * capacity 为空的房型视为未知容量,仍保留(避免老数据全被滤掉)。 + */ + private void applyGuestCapacityFilter(LambdaQueryWrapper wrapper, int needCapPerRoom) { + if (needCapPerRoom <= 1) { + return; + } + wrapper.apply(""" + EXISTS ( + SELECT 1 FROM canteen_hotel_room r + WHERE r.hotel_id = canteen_hotel.id + AND r.del_flag = '0' + AND (r.capacity IS NULL OR r.capacity >= {0}) + ) + """, needCapPerRoom); + } + + private static int resolveGuestTotal(Integer guests, Integer adults, Integer children) { + if (guests != null && guests > 0) { + return Math.min(guests, 40); + } + int a = adults == null ? 0 : Math.max(0, adults); + int c = children == null ? 0 : Math.max(0, children); + int total = a + c; + return Math.min(total, 40); + } + + private static LocalDate parseDay(String raw) { + if (StrUtil.isBlank(raw)) { + return null; + } + try { + String s = raw.trim().replace('/', '-'); + if (s.length() >= 10) { + s = s.substring(0, 10); + } + return LocalDate.parse(s, DAY); + } + catch (Exception e) { + return null; + } + } + + private static int clampInt(Object raw, int min, int max, int def) { + if (raw == null) { + return def; + } + try { + int v = Integer.parseInt(String.valueOf(raw).trim()); + return Math.max(min, Math.min(max, v)); + } + catch (Exception e) { + return def; + } + } + + private static Map defaultSearchPref() { + Map m = new LinkedHashMap<>(); + m.put("rooms", 1); + m.put("adults", 1); + m.put("children", 0); + m.put("nights", 1); + m.put("city", ""); + m.put("keyword", ""); + m.put("checkIn", null); + m.put("checkOut", null); + return m; + } + + private static Map toSearchPrefMap(SmHotelUserSearchPrefEntity row) { + Map m = defaultSearchPref(); + if (row == null) { + return m; + } + m.put("rooms", row.getRooms() == null ? 1 : row.getRooms()); + m.put("adults", row.getAdults() == null ? 1 : row.getAdults()); + m.put("children", row.getChildren() == null ? 0 : row.getChildren()); + m.put("nights", row.getNights() == null ? 1 : row.getNights()); + m.put("city", StrUtil.blankToDefault(row.getCity(), "")); + m.put("keyword", StrUtil.blankToDefault(row.getKeyword(), "")); + m.put("checkIn", row.getLastCheckIn() == null ? null : row.getLastCheckIn().toString()); + m.put("checkOut", row.getLastCheckOut() == null ? null : row.getLastCheckOut().toString()); + return m; + } + /** 按产品最低卖价落在区间内的酒店 id(起价筛选) */ private List loadHotelIdsByMinPriceRange(BigDecimal minPrice, BigDecimal maxPrice) { List products = Db.lambdaQuery(SmHotelProductEntity.class) diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java index fcc80bdd..d4ec11d5 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java @@ -39,6 +39,8 @@ public class SmHotelController { .like(StrUtil.isNotBlank(query.getGroupCode()), SmHotelEntity::getGroupCode, query.getGroupCode()) .eq(StrUtil.isNotBlank(query.getSourceId()), SmHotelEntity::getSourceId, query.getSourceId()) .eq(StrUtil.isNotBlank(query.getSheetName()), SmHotelEntity::getSheetName, query.getSheetName()) + // 归属部门:渠道映射按部门推送时,便于按部门筛酒店核查 + .eq(query.getDeptId() != null, SmHotelEntity::getDeptId, query.getDeptId()) .orderByDesc(SmHotelEntity::getCreateTime) .orderByDesc(SmHotelEntity::getId); // 位置区域:省/市前缀覆盖下级,并兼容仅有区域名称的老数据 diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelDownstreamAppController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelDownstreamAppController.java new file mode 100644 index 00000000..e0f145c1 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelDownstreamAppController.java @@ -0,0 +1,73 @@ +package com.bsd.pjcloud.supermarket.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; +import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAppService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/smHotelDownstreamApp") +@Tag(name = "酒店下游开放应用") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class SmHotelDownstreamAppController { + + private final SmHotelDownstreamAppService appService; + + @Operation(summary = "开放应用分页") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_view')") + public R> page(@ParameterObject Page page, + @ParameterObject SmHotelDownstreamAppEntity query) { + return R.ok(appService.pageApps(page, query)); + } + + @Operation(summary = "开放应用详情") + @GetMapping("/{id}") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_view')") + public R detail(@PathVariable Long id) { + SmHotelDownstreamAppEntity app = appService.getDetail(id); + return app == null ? R.failed("应用不存在") : R.ok(app); + } + + @SysLog("新增下游开放应用") + @Operation(summary = "新建开放应用(返回明文 secret 仅此一次)") + @PostMapping + @PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_edit')") + public R create(@RequestBody SmHotelDownstreamAppEntity body) { + return appService.createApp(body); + } + + @SysLog("修改下游开放应用") + @Operation(summary = "更新开放应用(不含密钥)") + @PutMapping + @PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_edit')") + public R update(@RequestBody SmHotelDownstreamAppEntity body) { + return appService.updateApp(body); + } + + @SysLog("删除下游开放应用") + @Operation(summary = "删除开放应用") + @DeleteMapping + @PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_edit')") + public R remove(@RequestBody Long[] ids) { + return appService.removeApps(ids); + } + + @SysLog("轮换下游开放应用密钥") + @Operation(summary = "重新生成 secret(返回明文仅此一次)") + @PostMapping("/rotateSecret/{id}") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_edit')") + public R rotateSecret(@PathVariable Long id) { + return appService.rotateSecret(id); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOpenApiController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOpenApiController.java new file mode 100644 index 00000000..33d39a2e --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOpenApiController.java @@ -0,0 +1,123 @@ +package com.bsd.pjcloud.supermarket.controller; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.supermarket.constant.SmHotelOpenApi; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAuth; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamOpenRateLimiter; +import com.bsd.pjcloud.supermarket.service.SmHotelOpenApiService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 酒店下游统一开放 API(对方按我方规范对接)。 + * 鉴权头:appKey / timestamp / nonce / sign;签名同环启 MD5 规则。 + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping(SmHotelOpenApi.BASE) +@Tag(name = "酒店下游开放平台") +public class SmHotelOpenApiController { + + private final DownstreamInboundAuth inboundAuth; + + private final DownstreamOpenRateLimiter rateLimiter; + + private final SmHotelOpenApiService openApiService; + + @Inner(value = false) + @Operation(summary = "酒店列表") + @PostMapping("/hotel/list") + public R> hotelList(HttpServletRequest request, @RequestBody String rawBody) { + return guarded(request, rawBody, SmHotelOpenApi.API_HOTEL_LIST, + (app, body) -> openApiService.hotelList(app, body)); + } + + @Inner(value = false) + @Operation(summary = "酒店详情(含房型/产品)") + @PostMapping("/hotel/detail") + public R> hotelDetail(HttpServletRequest request, @RequestBody String rawBody) { + return guarded(request, rawBody, SmHotelOpenApi.API_HOTEL_DETAIL, + (app, body) -> openApiService.hotelDetail(app, body)); + } + + @Inner(value = false) + @Operation(summary = "价态/库存日历") + @PostMapping("/hotel/daily") + public R> hotelDaily(HttpServletRequest request, @RequestBody String rawBody) { + return guarded(request, rawBody, SmHotelOpenApi.API_HOTEL_DAILY, + (app, body) -> openApiService.hotelDaily(app, body)); + } + + @Inner(value = false) + @Operation(summary = "下单前可订校验") + @PostMapping("/order/check") + public R> orderCheck(HttpServletRequest request, @RequestBody String rawBody) { + return guarded(request, rawBody, SmHotelOpenApi.API_ORDER_CHECK, + (app, body) -> openApiService.orderCheck(app, body)); + } + + @Inner(value = false) + @Operation(summary = "创建订单") + @PostMapping("/order/create") + public R orderCreate(HttpServletRequest request, @RequestBody String rawBody) { + return guarded(request, rawBody, SmHotelOpenApi.API_ORDER_CREATE, + (app, body) -> openApiService.orderCreate(app, body)); + } + + @Inner(value = false) + @Operation(summary = "取消订单") + @PostMapping("/order/cancel") + public R orderCancel(HttpServletRequest request, @RequestBody String rawBody) { + return guarded(request, rawBody, SmHotelOpenApi.API_ORDER_CANCEL, + (app, body) -> openApiService.orderCancel(app, body)); + } + + @Inner(value = false) + @Operation(summary = "查询订单") + @PostMapping("/order/query") + public R orderQuery(HttpServletRequest request, @RequestBody String rawBody) { + return guarded(request, rawBody, SmHotelOpenApi.API_ORDER_QUERY, + (app, body) -> openApiService.orderQuery(app, body)); + } + + private R guarded(HttpServletRequest request, String rawBody, String apiCode, Handler handler) { + try { + String appKey = header(request, "appKey"); + SmHotelDownstreamAppEntity app = inboundAuth.verify(appKey, header(request, "timestamp"), + header(request, "nonce"), header(request, "sign"), rawBody); + rateLimiter.acquire(app, apiCode); + Map body = JSONUtil.parseObj(StrUtil.blankToDefault(rawBody, "{}")); + return handler.handle(app, body); + } + catch (Exception e) { + log.warn("|开放API|{}|失败|{}", apiCode, e.getMessage()); + return R.failed(StrUtil.blankToDefault(e.getMessage(), "失败")); + } + } + + private static String header(HttpServletRequest request, String name) { + String v = request.getHeader(name); + return v != null ? v : request.getParameter(name); + } + + @FunctionalInterface + private interface Handler { + + R handle(SmHotelDownstreamAppEntity app, Map body); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java index d7ea715c..18f52ce9 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java @@ -34,6 +34,13 @@ public class SmHotelOrderController { return R.ok(orderService.pageOrders(page, query)); } + @Operation(summary = "订单来源筛选项(渠道下游 + 开放应用)") + @GetMapping("/orderSourceOptions") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')") + public R>> orderSourceOptions() { + return R.ok(orderService.listOrderSourceOptions()); + } + @Operation(summary = "订单详情(含酒店/产品/日历)") @GetMapping("/{id}") @PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')") diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamOpenCallbackClient.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamOpenCallbackClient.java new file mode 100644 index 00000000..25781989 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamOpenCallbackClient.java @@ -0,0 +1,144 @@ +package com.bsd.pjcloud.supermarket.integration.downstream; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 开放平台 → 下游回调(订单状态等)。失败仅打日志,不阻断主流程。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DownstreamOpenCallbackClient { + + private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4, r -> { + Thread t = new Thread(r, "sm-hotel-open-callback"); + t.setDaemon(true); + return t; + }); + + private final SmHotelDownstreamAppMapper appMapper; + + public void notifyOrderStatusAsync(Long downstreamAppId, SmHotelOrderEntity order) { + if (downstreamAppId == null || order == null) { + return; + } + EXECUTOR.execute(() -> { + try { + notifyOrderStatus(downstreamAppId, order); + } + catch (Exception e) { + log.warn("|开放回调|订单状态失败|appId={}|channelNo={}|{}", downstreamAppId, order.getChannelNo(), + e.getMessage()); + } + }); + } + + public void notifyOrderStatus(Long downstreamAppId, SmHotelOrderEntity order) { + SmHotelDownstreamAppEntity app = appMapper.selectById(downstreamAppId); + if (app == null || !"1".equals(app.getStatus()) || !"1".equals(app.getCallbackEnabled())) { + return; + } + if (StrUtil.isBlank(app.getCallbackBaseUrl())) { + return; + } + String path = StrUtil.blankToDefault(StrUtil.trim(app.getCallbackOrderPath()), "/order/status/notify"); + String url = joinUrl(app.getCallbackBaseUrl(), path); + Map body = new LinkedHashMap<>(12); + body.put("channelNo", order.getChannelNo()); + body.put("orderNo", order.getId() == null ? null : String.valueOf(order.getId())); + body.put("platformNo", order.getPlatformNo()); + body.put("orderStatus", order.getOrderStatus()); + body.put("bookStatus", order.getBookStatus()); + body.put("hotelCode", order.getHotelCode()); + body.put("productCode", order.getProductCode()); + body.put("sellAmount", order.getSellAmount()); + body.put("failMsg", order.getFailMsg()); + String bodyJson = JSONUtil.toJsonStr(body); + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = IdUtil.fastSimpleUUID(); + String sign = DownstreamSignHelper.sign(app.getAppKey(), app.getAppSecret(), timestamp, nonce, bodyJson); + try (HttpResponse res = HttpRequest.post(url) + .header("appKey", app.getAppKey()) + .header("timestamp", timestamp) + .header("nonce", nonce) + .header("sign", sign) + .header("Content-Type", "application/json;charset=UTF-8") + .body(bodyJson) + .timeout(15000) + .execute()) { + log.info("|开放回调|订单状态|appKey={}|url={}|http={}|channelNo={}|", app.getAppKey(), url, res.getStatus(), + order.getChannelNo()); + } + } + + /** + * 资源变更通知(房价/库存等)。路径未配置则跳过。 + * 后续由本地价态变更事件批量触发;当前可供手动/任务调用。 + */ + public void notifyResourceChangeAsync(Long downstreamAppId, String hotelCode, String changeType, + Object payload) { + if (downstreamAppId == null || StrUtil.isBlank(hotelCode)) { + return; + } + EXECUTOR.execute(() -> { + try { + SmHotelDownstreamAppEntity app = appMapper.selectById(downstreamAppId); + if (app == null || !"1".equals(app.getStatus()) || !"1".equals(app.getCallbackEnabled())) { + return; + } + if (StrUtil.isBlank(app.getCallbackBaseUrl()) || StrUtil.isBlank(app.getCallbackResourcePath())) { + return; + } + String url = joinUrl(app.getCallbackBaseUrl(), app.getCallbackResourcePath()); + Map body = new LinkedHashMap<>(8); + body.put("hotelCode", hotelCode); + body.put("changeType", StrUtil.blankToDefault(changeType, "DAILY")); + body.put("payload", payload); + String bodyJson = JSONUtil.toJsonStr(body); + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = IdUtil.fastSimpleUUID(); + String sign = DownstreamSignHelper.sign(app.getAppKey(), app.getAppSecret(), timestamp, nonce, + bodyJson); + try (HttpResponse res = HttpRequest.post(url) + .header("appKey", app.getAppKey()) + .header("timestamp", timestamp) + .header("nonce", nonce) + .header("sign", sign) + .header("Content-Type", "application/json;charset=UTF-8") + .body(bodyJson) + .timeout(15000) + .execute()) { + log.info("|开放回调|资源变更|appKey={}|hotel={}|http={}|", app.getAppKey(), hotelCode, + res.getStatus()); + } + } + catch (Exception e) { + log.warn("|开放回调|资源变更失败|appId={}|hotel={}|{}", downstreamAppId, hotelCode, e.getMessage()); + } + }); + } + + private static String joinUrl(String base, String path) { + String b = StrUtil.removeSuffix(StrUtil.trim(base), "/"); + String p = StrUtil.trim(path); + if (!p.startsWith("/")) { + p = "/" + p; + } + return b + p; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamOpenRateLimiter.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamOpenRateLimiter.java new file mode 100644 index 00000000..cc7296ba --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamOpenRateLimiter.java @@ -0,0 +1,77 @@ +package com.bsd.pjcloud.supermarket.integration.downstream; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.common.core.exception.CheckedException; +import com.bsd.pjcloud.supermarket.constant.SmHotelOpenApi; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * 开放平台入站限流:按 appKey + 接口码独立秒窗 QPS,应用间互不影响。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DownstreamOpenRateLimiter { + + private static final String KEY = "sm:hotel:open:qps:"; + + private final StringRedisTemplate redisTemplate; + + public void acquire(SmHotelDownstreamAppEntity app, String apiCode) { + if (app == null || StrUtil.isBlank(app.getAppKey())) { + return; + } + int qps = resolveQps(app.getRateLimitJson(), apiCode); + if (qps <= 0) { + return; + } + long second = System.currentTimeMillis() / 1000L; + String redisKey = KEY + app.getAppKey() + ":" + StrUtil.blankToDefault(apiCode, "default") + ":" + second; + Long n = redisTemplate.opsForValue().increment(redisKey); + if (n != null && n == 1L) { + redisTemplate.expire(redisKey, 3, TimeUnit.SECONDS); + } + if (n != null && n > qps) { + log.warn("|开放平台限流|appKey={}|api={}|qps={}|hit={}|", app.getAppKey(), apiCode, qps, n); + throw new CheckedException("请求过于频繁,请降低调用频率(" + apiCode + " QPS=" + qps + ")"); + } + } + + public static int resolveQps(String rateLimitJson, String apiCode) { + int def = SmHotelOpenApi.DEFAULT_QPS; + if (StrUtil.isBlank(rateLimitJson)) { + return defaultForApi(apiCode, def); + } + try { + JSONObject jo = JSONUtil.parseObj(rateLimitJson); + if (StrUtil.isNotBlank(apiCode) && jo.get(apiCode) != null) { + return Math.max(0, jo.getInt(apiCode)); + } + if (jo.get("default") != null) { + def = Math.max(0, jo.getInt("default")); + } + return defaultForApi(apiCode, def); + } + catch (Exception e) { + return defaultForApi(apiCode, def); + } + } + + private static int defaultForApi(String apiCode, int def) { + if (SmHotelOpenApi.API_ORDER_CREATE.equals(apiCode) || SmHotelOpenApi.API_ORDER_CANCEL.equals(apiCode)) { + return Math.min(def, 5); + } + if (SmHotelOpenApi.API_HOTEL_LIST.equals(apiCode) || SmHotelOpenApi.API_ORDER_CHECK.equals(apiCode)) { + return Math.min(def, 10); + } + return def; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java index 6ec6fa12..e7e6b16d 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java @@ -30,6 +30,18 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper= #{fromDate} + AND stay_date < #{toExclusive} + """) + List selectDistinctHotelIdsByStayRange(@Param("fromDate") LocalDate fromDate, + @Param("toExclusive") LocalDate toExclusive); + int insertBatch(@Param("list") List list); /** diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelRoomMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelRoomMapper.java index 018b5fb7..e6365a0b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelRoomMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelRoomMapper.java @@ -24,4 +24,12 @@ public interface SmHotelRoomMapper extends PjcloudBaseMapper """) List> countGroupByHotelIds(@Param("ids") Collection ids); + + /** 移动端列表:房型可住人数达标(或未填容量)的酒店 */ + @Select(""" + SELECT DISTINCT hotel_id FROM canteen_hotel_room + WHERE del_flag = '0' + AND (capacity IS NULL OR capacity >= #{minCapacity}) + """) + List selectDistinctHotelIdsByMinCapacity(@Param("minCapacity") int minCapacity); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelUserSearchPrefMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelUserSearchPrefMapper.java new file mode 100644 index 00000000..5e15a27e --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelUserSearchPrefMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.supermarket.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelUserSearchPrefEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SmHotelUserSearchPrefMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamAppService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamAppService.java new file mode 100644 index 00000000..b8e6ad92 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamAppService.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.supermarket.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; + +public interface SmHotelDownstreamAppService extends IService { + + Page pageApps(Page page, SmHotelDownstreamAppEntity query); + + SmHotelDownstreamAppEntity getDetail(Long id); + + R createApp(SmHotelDownstreamAppEntity entity); + + R updateApp(SmHotelDownstreamAppEntity entity); + + R removeApps(Long[] ids); + + /** 重新生成密钥,返回含明文 secret 的对象(仅此一次) */ + R rotateSecret(Long id); + + SmHotelDownstreamAppEntity getEnabledByAppKey(String appKey); + + /** 订单来源编码:优先 partnerCode,否则 appKey */ + String resolvePartnerCode(SmHotelDownstreamAppEntity app); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenApiService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenApiService.java new file mode 100644 index 00000000..d00c9da0 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenApiService.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.supermarket.service; + +import cn.hutool.json.JSONObject; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; + +import java.util.Map; + +/** + * 下游统一开放 API 业务(对方调我方)。 + */ +public interface SmHotelOpenApiService { + + R> hotelList(SmHotelDownstreamAppEntity app, Map body); + + R> hotelDetail(SmHotelDownstreamAppEntity app, Map body); + + R> hotelDaily(SmHotelDownstreamAppEntity app, Map body); + + R> orderCheck(SmHotelDownstreamAppEntity app, Map body); + + R orderCreate(SmHotelDownstreamAppEntity app, Map body); + + R orderCancel(SmHotelDownstreamAppEntity app, Map body); + + R orderQuery(SmHotelDownstreamAppEntity app, Map body); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java index 3ba69266..fb66af27 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java @@ -17,6 +17,9 @@ public interface SmHotelOrderService { Page pageOrders(Page page, SmHotelOrderEntity query); + /** 订单来源筛选项:渠道下游 + 开放应用(各自显示名称) */ + List> listOrderSourceOptions(); + /** App「我的订单」:按用户 ID 绑定(channelNo 前缀 APP{userId}) */ Page pageMyAppOrders(Page page, PjcloudUser user, String orderStatus); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamAppServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamAppServiceImpl.java new file mode 100644 index 00000000..e59d6fda --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamAppServiceImpl.java @@ -0,0 +1,180 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAppService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; + +@Service +public class SmHotelDownstreamAppServiceImpl extends ServiceImpl + implements SmHotelDownstreamAppService { + + private static final String DEFAULT_RATE_JSON = "{\"default\":20,\"hotel.list\":10,\"hotel.detail\":20," + + "\"hotel.daily\":20,\"order.check\":10,\"order.create\":5,\"order.cancel\":5,\"order.query\":20}"; + + @Override + public Page pageApps(Page page, + SmHotelDownstreamAppEntity query) { + Page result = baseMapper.selectPage(page, + Wrappers.lambdaQuery() + .like(StrUtil.isNotBlank(query.getAppName()), SmHotelDownstreamAppEntity::getAppName, + query.getAppName()) + .like(StrUtil.isNotBlank(query.getAppKey()), SmHotelDownstreamAppEntity::getAppKey, + query.getAppKey()) + .like(StrUtil.isNotBlank(query.getPartnerCode()), SmHotelDownstreamAppEntity::getPartnerCode, + query.getPartnerCode()) + .eq(StrUtil.isNotBlank(query.getStatus()), SmHotelDownstreamAppEntity::getStatus, + query.getStatus()) + .orderByDesc(SmHotelDownstreamAppEntity::getUpdateTime) + .orderByDesc(SmHotelDownstreamAppEntity::getCreateTime)); + if (result.getRecords() != null) { + result.getRecords().forEach(this::maskSecret); + } + return result; + } + + @Override + public SmHotelDownstreamAppEntity getDetail(Long id) { + SmHotelDownstreamAppEntity app = baseMapper.selectById(id); + if (app != null) { + maskSecret(app); + } + return app; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R createApp(SmHotelDownstreamAppEntity entity) { + if (entity == null || StrUtil.isBlank(entity.getAppName())) { + return R.failed("应用名称不能为空"); + } + String appKey = StrUtil.trim(entity.getAppKey()); + if (StrUtil.isBlank(appKey)) { + appKey = "OA" + IdWorker.getIdStr(); + } + if (existsAppKey(appKey, null)) { + return R.failed("appKey 已存在:" + appKey); + } + entity.setAppKey(appKey); + if (StrUtil.isBlank(entity.getAppSecret())) { + entity.setAppSecret(genSecret()); + } + entity.setPartnerCode(StrUtil.blankToDefault(StrUtil.trim(entity.getPartnerCode()), appKey)); + entity.setStatus(StrUtil.blankToDefault(entity.getStatus(), "1")); + entity.setCallbackEnabled(StrUtil.blankToDefault(entity.getCallbackEnabled(), "0")); + entity.setCallbackOrderPath(StrUtil.blankToDefault(StrUtil.trim(entity.getCallbackOrderPath()), + "/order/status/notify")); + if (StrUtil.isBlank(entity.getRateLimitJson())) { + entity.setRateLimitJson(DEFAULT_RATE_JSON); + } + baseMapper.insert(entity); + // 创建时回传明文 secret,方便复制发给对方 + return R.ok(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R updateApp(SmHotelDownstreamAppEntity entity) { + if (entity == null || entity.getId() == null) { + return R.failed("id 不能为空"); + } + SmHotelDownstreamAppEntity db = baseMapper.selectById(entity.getId()); + if (db == null) { + return R.failed("应用不存在"); + } + // appKey / secret 不允许在普通更新里改;密钥走 rotate + db.setAppName(StrUtil.trim(entity.getAppName())); + db.setPartnerCode(StrUtil.blankToDefault(StrUtil.trim(entity.getPartnerCode()), db.getAppKey())); + db.setCallbackBaseUrl(StrUtil.trim(entity.getCallbackBaseUrl())); + db.setCallbackOrderPath(StrUtil.blankToDefault(StrUtil.trim(entity.getCallbackOrderPath()), + "/order/status/notify")); + db.setCallbackResourcePath(StrUtil.trim(entity.getCallbackResourcePath())); + db.setCallbackEnabled(StrUtil.blankToDefault(entity.getCallbackEnabled(), "0")); + if (StrUtil.isNotBlank(entity.getRateLimitJson())) { + db.setRateLimitJson(entity.getRateLimitJson()); + } + db.setStatus(StrUtil.blankToDefault(entity.getStatus(), db.getStatus())); + db.setRemark(entity.getRemark()); + baseMapper.updateById(db); + maskSecret(db); + return R.ok(db); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R removeApps(Long[] ids) { + if (ids == null || ids.length == 0) { + return R.failed("请选择要删除的应用"); + } + List list = Arrays.asList(ids); + baseMapper.deleteByIds(list); + return R.ok(Boolean.TRUE); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R rotateSecret(Long id) { + SmHotelDownstreamAppEntity db = baseMapper.selectById(id); + if (db == null) { + return R.failed("应用不存在"); + } + db.setAppSecret(genSecret()); + baseMapper.updateById(db); + return R.ok(db); + } + + @Override + public SmHotelDownstreamAppEntity getEnabledByAppKey(String appKey) { + if (StrUtil.isBlank(appKey)) { + return null; + } + return baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelDownstreamAppEntity::getAppKey, appKey.trim()) + .eq(SmHotelDownstreamAppEntity::getStatus, "1") + .last("limit 1")); + } + + @Override + public String resolvePartnerCode(SmHotelDownstreamAppEntity app) { + if (app == null) { + return null; + } + return StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()), StrUtil.trim(app.getAppKey())); + } + + private boolean existsAppKey(String appKey, Long excludeId) { + Long cnt = baseMapper.selectCount(Wrappers.lambdaQuery() + .eq(SmHotelDownstreamAppEntity::getAppKey, appKey) + .ne(excludeId != null, SmHotelDownstreamAppEntity::getId, excludeId)); + return cnt != null && cnt > 0; + } + + private static String genSecret() { + return IdUtil.fastSimpleUUID() + IdUtil.fastSimpleUUID().substring(0, 8); + } + + private void maskSecret(SmHotelDownstreamAppEntity app) { + if (app == null) { + return; + } + String s = app.getAppSecret(); + if (StrUtil.isBlank(s) || s.length() <= 6) { + app.setAppSecretMasked("******"); + } + else { + app.setAppSecretMasked(s.substring(0, 2) + "****" + s.substring(s.length() - 4)); + } + app.setAppSecret(null); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java index c199bcfa..7c9b9a91 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java @@ -5,8 +5,6 @@ import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.bsd.pjcloud.common.core.util.R; -import com.bsd.pjcloud.common.data.tenant.TenantBroker; -import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties; import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol; import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; @@ -32,11 +30,9 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; /** - * 慧订订单履约:直连校验/创建/取消 + 通知补单 + operationOrder 回写。 + * 慧订订单履约:校验/创建/取消同步返回;创建时同步订上游,成败当场返回(不再异步 operationOrder)。 */ @Slf4j @Service @@ -46,12 +42,6 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE; - private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4, r -> { - Thread t = new Thread(r, "sm-hotel-huiding-order"); - t.setDaemon(true); - return t; - }); - private final SmHotelChannelConfigService channelConfigService; private final SmHotelOrderService hotelOrderService; @@ -149,7 +139,6 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic } @Override - @Transactional(rollbackFor = Exception.class) public Map create(String partnerCode, JSONObject data) { Map outer = ok200(); String channelOrderNo = StrUtil.trim(str(data.get("channelOrderNo"))); @@ -209,11 +198,27 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic order.setHuanqiPayload(data.toString()); orderMapper.insert(order); - Long tenantId = TenantContextHolder.getTenantId() == null ? 1L : TenantContextHolder.getTenantId(); - scheduleBookAndConfirm(tenantId, partnerCode, order.getId(), channelOrderNo, mapped.orderAmount); - - outer.put("msg", "下单成功,订单状态:待确认"); - outer.put("data", buildCreateData(order)); + // 同步订上游:当场返回成败,不走「先待确认再 operationOrder」中间态 + R book = hotelOrderService.bookUpstreamForExisting(order.getId()); + SmHotelOrderEntity latest = orderMapper.selectById(order.getId()); + if (latest == null) { + return fail200("下单后订单丢失"); + } + if (book == null || book.getCode() != 0 || !isBookConfirmed(latest)) { + String failMsg = latest.getFailMsg(); + if (StrUtil.isBlank(failMsg)) { + failMsg = book == null ? "上游下单失败" : StrUtil.blankToDefault(book.getMsg(), "上游下单失败"); + } + if (!"UPSTREAM_FAIL".equals(latest.getOrderStatus()) && !"NEED_MANUAL".equals(latest.getOrderStatus()) + && !"CANCELLED".equals(latest.getOrderStatus())) { + latest.setOrderStatus("UPSTREAM_FAIL"); + latest.setFailMsg(StrUtil.maxLength(failMsg, 500)); + orderMapper.updateById(latest); + } + return fail200(failMsg); + } + outer.put("msg", "下单成功"); + outer.put("data", buildCreateData(latest)); return outer; } @@ -249,14 +254,8 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic if (r == null || r.getCode() != 0) { return fail200(r == null ? "取消失败" : r.getMsg()); } - try { - operationOrder(partnerCode, order.getChannelNo(), String.valueOf(order.getId()), 3, null); - } - catch (Exception e) { - log.warn("|{}|取消确认回写失败|channelNo={}|{}", TAG, channelOrderNo, e.getMessage()); - } Map outer = ok200(); - outer.put("msg", "申请退单成功"); + outer.put("msg", "取消成功"); outer.put("data", true); return outer; } @@ -282,12 +281,19 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic .eq(SmHotelOrderEntity::getChannelNo, channelNo) .last("limit 1")); if (exist != null) { - Long tenantId = TenantContextHolder.getTenantId() == null ? 1L : TenantContextHolder.getTenantId(); - if (StrUtil.isBlank(exist.getPlatformNo()) && !"CANCELLED".equals(exist.getOrderStatus())) { - scheduleBookAndConfirm(tenantId, partner, exist.getId(), exist.getChannelNo(), exist.getSellAmount()); + if (StrUtil.isBlank(exist.getPlatformNo()) && !"CANCELLED".equals(exist.getOrderStatus()) + && !isBookConfirmed(exist)) { + R book = hotelOrderService.bookUpstreamForExisting(exist.getId()); + SmHotelOrderEntity latest = orderMapper.selectById(exist.getId()); + if (book == null || book.getCode() != 0 || latest == null || !isBookConfirmed(latest)) { + resp.put("code", 1); + resp.put("message", latest != null && StrUtil.isNotBlank(latest.getFailMsg()) ? latest.getFailMsg() + : (book == null ? "上游下单失败" : StrUtil.blankToDefault(book.getMsg(), "上游下单失败"))); + return resp; + } } resp.put("code", 0); - resp.put("message", "本地已有订单,已触发履约"); + resp.put("message", "本地已有订单,履约完成"); return resp; } // 本地没有:尝试查对方 @@ -341,56 +347,18 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic } } - private void scheduleBookAndConfirm(Long tenantId, String partnerCode, Long orderId, String childOrderCode, - BigDecimal totalPrice) { - EXECUTOR.execute(() -> TenantBroker.runAs(tenantId, tid -> { - try { - R r = hotelOrderService.bookUpstreamForExisting(orderId); - SmHotelOrderEntity order = orderMapper.selectById(orderId); - if (r != null && r.getCode() == 0) { - operationOrder(partnerCode, childOrderCode, - order == null ? String.valueOf(orderId) - : StrUtil.blankToDefault(order.getPlatformNo(), String.valueOf(orderId)), - 1, totalPrice); - } - else { - log.warn("|{}|上游下单失败,回写拒绝|orderId={}|msg={}|", TAG, orderId, - r == null ? "null" : r.getMsg()); - operationOrder(partnerCode, childOrderCode, String.valueOf(orderId), 2, null); - if (order != null) { - order.setOrderStatus("NEED_MANUAL"); - order.setFailMsg(StrUtil.maxLength(r == null ? "上游下单失败" : r.getMsg(), 500)); - orderMapper.updateById(order); - } - } - } - catch (Exception e) { - log.warn("|{}|异步履约失败|orderId={}|{}", TAG, orderId, e.getMessage()); - try { - operationOrder(partnerCode, childOrderCode, String.valueOf(orderId), 2, null); - } - catch (Exception ignored) { - } - } - })); - } - - private void operationOrder(String partnerCode, String childOrderCode, String supplierOrderCode, int operationType, - BigDecimal totalPrice) { - HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(partnerCode); - Map body = new LinkedHashMap<>(); - body.put("key", HuidingDownstreamHotelAdapter.resolveKey(props)); - body.put("supplierId", props.getSupplierId()); - body.put("childOrderCode", childOrderCode); - body.put("operationType", operationType); - if (StrUtil.isNotBlank(supplierOrderCode) && (operationType == 1 || operationType == 3)) { - body.put("supplierOrderCode", supplierOrderCode); + /** 上游已明确订成(含本地库存单) */ + private static boolean isBookConfirmed(SmHotelOrderEntity order) { + if (order == null) { + return false; } - if (totalPrice != null && operationType == 1) { - body.put("totalPrice", totalPrice); + if ("UPSTREAM_OK".equals(order.getOrderStatus()) || "CHECKED_IN".equals(order.getOrderStatus())) { + return true; } - downstreamClient.huidingOperationOrder(partnerCode, body); - log.info("|{}|operationOrder|partner={}|child={}|type={}|", TAG, partnerCode, childOrderCode, operationType); + return StrUtil.isNotBlank(order.getPlatformNo()) + && !"UPSTREAM_FAIL".equals(order.getOrderStatus()) + && !"NEED_MANUAL".equals(order.getOrderStatus()) + && !"CANCELLED".equals(order.getOrderStatus()); } private String resolvePartnerByNotifyPass(String pass) { @@ -508,9 +476,20 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic private static Map buildCreateData(SmHotelOrderEntity order) { Map data = new LinkedHashMap<>(6); data.put("orderNo", order.getId() == null ? order.getChannelNo() : String.valueOf(order.getId())); - data.put("orderStatus", "1"); - data.put("orderStatusDesc", "待确认"); + if (isBookConfirmed(order)) { + data.put("orderStatus", "3"); + data.put("orderStatusDesc", "待入住"); + } + else if ("CANCELLED".equals(order.getOrderStatus())) { + data.put("orderStatus", "5"); + data.put("orderStatusDesc", "已取消"); + } + else { + data.put("orderStatus", "1"); + data.put("orderStatusDesc", "待确认"); + } data.put("orderAmount", order.getSellAmount()); + data.put("platformNo", order.getPlatformNo()); return data; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java index fecbe10a..27ef129b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java @@ -904,7 +904,21 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { for (String code : codes) { Map row = new LinkedHashMap<>(4); row.put("partnerCode", code); - row.put("label", code); + HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(code); + String protocol = props == null ? null : props.getProtocol(); + String protocolLabel = SmHotelDownstreamProtocol.label(protocol); + row.put("protocol", SmHotelDownstreamProtocol.normalize(protocol)); + SmHotelChannelConfigEntity cfg = channelConfigService.getByChannel(code, + com.bsd.pjcloud.supermarket.constant.SmHotelChannelDirection.DOWNSTREAM); + String remark = cfg == null ? null : StrUtil.trim(cfg.getRemark()); + String label; + if (StrUtil.isNotBlank(remark)) { + label = StrUtil.isNotBlank(protocolLabel) ? protocolLabel + "·" + remark : remark; + } + else { + label = StrUtil.isNotBlank(protocolLabel) ? protocolLabel + "·" + code : code; + } + row.put("label", label); options.add(row); } return R.ok(options); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenApiServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenApiServiceImpl.java new file mode 100644 index 00000000..a9078c5d --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenApiServiceImpl.java @@ -0,0 +1,239 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +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.entity.SmHotelDailyEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity; +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.SmHotelOpenApiService; +import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { + + private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE; + + private final SmHotelMapper hotelMapper; + + private final SmHotelRoomMapper roomMapper; + + private final SmHotelProductMapper productMapper; + + private final SmHotelDailyMapper dailyMapper; + + private final SmHotelOrderService hotelOrderService; + + @Override + public R> hotelList(SmHotelDownstreamAppEntity app, Map body) { + 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 page = hotelMapper.selectPage(new Page<>(current, size), + Wrappers.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)); + List> records = new ArrayList<>(); + for (SmHotelEntity h : page.getRecords()) { + records.add(toHotelBrief(h)); + } + Map data = new LinkedHashMap<>(8); + data.put("records", records); + data.put("total", page.getTotal()); + data.put("current", page.getCurrent()); + data.put("size", page.getSize()); + return R.ok(data); + } + + @Override + public R> hotelDetail(SmHotelDownstreamAppEntity app, Map body) { + String hotelCode = StrUtil.trim(str(body.get("hotelCode"))); + if (StrUtil.isBlank(hotelCode)) { + return R.failed("hotelCode 不能为空"); + } + SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelEntity::getHotelCode, hotelCode) + .last("limit 1")); + if (hotel == null) { + return R.failed("酒店不存在"); + } + Map data = toHotelBrief(hotel); + data.put("coverImage", hotel.getCoverImage()); + data.put("carouselImages", hotel.getCarouselImages()); + data.put("intro", hotel.getIntro()); + data.put("starLevel", hotel.getStarLevel()); + List rooms = roomMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelRoomEntity::getHotelId, hotel.getId()) + .orderByAsc(SmHotelRoomEntity::getRoomTypeCode)); + List> roomList = new ArrayList<>(); + for (SmHotelRoomEntity r : rooms) { + Map row = new LinkedHashMap<>(6); + row.put("roomTypeCode", r.getRoomTypeCode()); + row.put("roomTypeName", r.getRoomTypeName()); + row.put("bedType", r.getBedType()); + row.put("capacity", r.getCapacity()); + roomList.add(row); + } + data.put("rooms", roomList); + List products = productMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, hotel.getId()) + .orderByAsc(SmHotelProductEntity::getProductCode)); + List> productList = new ArrayList<>(); + for (SmHotelProductEntity p : products) { + Map row = new LinkedHashMap<>(8); + row.put("productCode", p.getProductCode()); + row.put("productName", p.getProductName()); + row.put("roomTypeCode", p.getRoomTypeCode()); + row.put("priceCode", p.getPriceCode()); + row.put("mealType", p.getMealType()); + row.put("mealCount", p.getMealCount()); + row.put("sellPrice", p.getSellPrice()); + productList.add(row); + } + data.put("products", productList); + return R.ok(data); + } + + @Override + public R> hotelDaily(SmHotelDownstreamAppEntity app, Map body) { + String hotelCode = StrUtil.trim(str(body.get("hotelCode"))); + String productCode = StrUtil.trim(str(body.get("productCode"))); + LocalDate start = parseDay(str(body.get("startDate"))); + LocalDate end = parseDay(str(body.get("endDate"))); + if (StrUtil.isBlank(hotelCode) || start == null || end == null || end.isBefore(start)) { + return R.failed("hotelCode/startDate/endDate 无效"); + } + if (start.plusDays(90).isBefore(end)) { + return R.failed("查询窗口最长 90 天"); + } + SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelEntity::getHotelCode, hotelCode) + .last("limit 1")); + if (hotel == null) { + return R.failed("酒店不存在"); + } + List dailies = dailyMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelDailyEntity::getHotelId, hotel.getId()) + .eq(StrUtil.isNotBlank(productCode), SmHotelDailyEntity::getProductCode, productCode) + .ge(SmHotelDailyEntity::getStayDate, start) + .le(SmHotelDailyEntity::getStayDate, end) + .orderByAsc(SmHotelDailyEntity::getProductCode) + .orderByAsc(SmHotelDailyEntity::getStayDate) + .last("limit 5000")); + List> list = new ArrayList<>(); + for (SmHotelDailyEntity d : dailies) { + Map row = new LinkedHashMap<>(10); + row.put("productCode", d.getProductCode()); + row.put("stayDate", d.getStayDate() == null ? null : d.getStayDate().toString()); + row.put("sellPrice", d.getSellPrice()); + row.put("stock", d.getStock()); + row.put("sold", d.getSold()); + row.put("remain", d.getRemainStock()); + row.put("dailyStatus", d.getDailyStatus()); + list.add(row); + } + Map data = new LinkedHashMap<>(4); + data.put("hotelCode", hotelCode); + data.put("list", list); + return R.ok(data); + } + + @Override + public R> orderCheck(SmHotelDownstreamAppEntity app, Map body) { + Map checkBody = new LinkedHashMap<>(); + checkBody.put("extHotelId", body.get("hotelCode")); + checkBody.put("extProductId", body.get("productCode")); + checkBody.put("extApartmentId", body.get("roomTypeCode")); + checkBody.put("checkIn", body.get("checkInDate")); + 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); + } + + @Override + public R orderCreate(SmHotelDownstreamAppEntity app, Map body) { + // 保证订单来源写入伙伴编码 + R r = hotelOrderService.createFromDownstream(app.getAppKey(), body); + return r; + } + + @Override + public R orderCancel(SmHotelDownstreamAppEntity app, Map body) { + R r = hotelOrderService.cancelFromDownstream(app.getAppKey(), body); + return r; + } + + @Override + public R orderQuery(SmHotelDownstreamAppEntity app, Map body) { + return hotelOrderService.queryFromDownstream(app.getAppKey(), body); + } + + private Map toHotelBrief(SmHotelEntity h) { + Map row = new LinkedHashMap<>(12); + 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("longitude", h.getLongitude()); + row.put("latitude", h.getLatitude()); + row.put("dataSource", h.getSheetName()); + row.put("status", h.getUpstreamStatus()); + return row; + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static long toLong(Object o, long def) { + if (o == null) { + return def; + } + try { + return Long.parseLong(String.valueOf(o)); + } + catch (Exception e) { + return def; + } + } + + private static LocalDate parseDay(String s) { + if (StrUtil.isBlank(s)) { + return null; + } + try { + return LocalDate.parse(s.trim(), DAY); + } + catch (Exception e) { + return null; + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java index eddcf34b..2c6084f8 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java @@ -8,9 +8,11 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource; +import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol; import com.bsd.pjcloud.supermarket.constant.SmHotelInventoryStatus; import com.bsd.pjcloud.supermarket.entity.*; import com.bsd.pjcloud.supermarket.event.HotelUpstreamFailRefundEvent; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamOpenCallbackClient; import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderGateway; import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderGatewayRegistry; import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderResult; @@ -98,6 +100,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl> listOrderSourceOptions() { + List> options = new ArrayList<>(); + java.util.LinkedHashSet seen = new java.util.LinkedHashSet<>(); + for (SmHotelChannelConfigEntity cfg : channelConfigService.listDownstreamPartners()) { + if (cfg == null || StrUtil.isBlank(cfg.getChannel()) || !"1".equals(cfg.getEnabled())) { + continue; + } + String code = cfg.getChannel().trim(); + if (!seen.add(code)) { + continue; + } + String protocol = SmHotelDownstreamProtocol.normalize(cfg.getProtocol()); + String protocolLabel = SmHotelDownstreamProtocol.label(protocol); + String remark = StrUtil.trim(cfg.getRemark()); + String label = StrUtil.isNotBlank(remark) ? protocolLabel + "·" + remark : protocolLabel + "·" + code; + Map row = new LinkedHashMap<>(6); + row.put("partnerCode", code); + row.put("protocol", protocol); + row.put("label", label); + row.put("type", "channel"); + options.add(row); + } + List apps = appMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelDownstreamAppEntity::getStatus, "1") + .orderByAsc(SmHotelDownstreamAppEntity::getAppName) + .orderByAsc(SmHotelDownstreamAppEntity::getAppKey)); + for (SmHotelDownstreamAppEntity app : apps) { + String code = StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()), StrUtil.trim(app.getAppKey())); + if (StrUtil.isBlank(code) || !seen.add(code)) { + continue; + } + String name = StrUtil.blankToDefault(app.getAppName(), code); + Map row = new LinkedHashMap<>(6); + row.put("partnerCode", code); + row.put("protocol", "open"); + row.put("label", "开放·" + name); + row.put("type", "open"); + row.put("downstreamAppId", app.getId()); + options.add(row); + } + return options; + } + @Override public Page pageMyAppOrders(Page page, PjcloudUser user, String orderStatus) { @@ -154,7 +206,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl records) { if (records == null || records.isEmpty()) { @@ -188,6 +241,51 @@ public class SmHotelOrderServiceImpl extends ServiceImpl partnerProtocolMap = new HashMap<>(); + Map partnerNameMap = new HashMap<>(); + for (SmHotelChannelConfigEntity cfg : channelConfigService.listDownstreamPartners()) { + if (cfg == null || StrUtil.isBlank(cfg.getChannel())) { + continue; + } + String code = cfg.getChannel().trim(); + String protocol = SmHotelDownstreamProtocol.normalize(cfg.getProtocol()); + partnerProtocolMap.put(code, protocol); + String name = StrUtil.trim(cfg.getRemark()); + if (StrUtil.isBlank(name)) { + name = SmHotelDownstreamProtocol.label(protocol) + "·" + code; + } + partnerNameMap.put(code, name); + } + // 开放应用:按 id / partnerCode / appKey 映射名称 + List appIds = records.stream() + .map(SmHotelOrderEntity::getDownstreamAppId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + Map appById = new HashMap<>(); + if (!appIds.isEmpty()) { + for (SmHotelDownstreamAppEntity app : appMapper.selectByIds(appIds)) { + appById.put(app.getId(), app); + indexOpenAppNames(partnerNameMap, partnerProtocolMap, app); + } + } + List partnerCodes = records.stream() + .map(SmHotelOrderEntity::getPartnerCode) + .filter(StrUtil::isNotBlank) + .map(String::trim) + .filter(c -> !partnerNameMap.containsKey(c)) + .distinct() + .collect(Collectors.toList()); + if (!partnerCodes.isEmpty()) { + List apps = appMapper.selectList(Wrappers.lambdaQuery() + .and(w -> w.in(SmHotelDownstreamAppEntity::getPartnerCode, partnerCodes) + .or() + .in(SmHotelDownstreamAppEntity::getAppKey, partnerCodes))); + for (SmHotelDownstreamAppEntity app : apps) { + indexOpenAppNames(partnerNameMap, partnerProtocolMap, app); + } + } for (SmHotelOrderEntity o : records) { SmHotelEntity hotel = o.getHotelId() != null ? hotelById.get(o.getHotelId()) : null; if (hotel == null && StrUtil.isNotBlank(o.getHotelCode())) { @@ -197,20 +295,107 @@ public class SmHotelOrderServiceImpl extends ServiceImpl partnerNameMap, + Map partnerProtocolMap, SmHotelDownstreamAppEntity app) { + if (app == null) { + return; + } + String display = StrUtil.blankToDefault(StrUtil.trim(app.getAppName()), + StrUtil.blankToDefault(app.getPartnerCode(), app.getAppKey())); + if (StrUtil.isNotBlank(app.getPartnerCode())) { + partnerNameMap.putIfAbsent(app.getPartnerCode().trim(), display); + partnerProtocolMap.putIfAbsent(app.getPartnerCode().trim(), "open"); + } + if (StrUtil.isNotBlank(app.getAppKey())) { + partnerNameMap.putIfAbsent(app.getAppKey().trim(), display); + partnerProtocolMap.putIfAbsent(app.getAppKey().trim(), "open"); + } + } + + /** 解析订单销售来源(与酒店 sheet_name 数据来源分离);不同下游显示各自名称。 */ + private void fillOrderSourceFields(SmHotelOrderEntity o, SmHotelDownstreamAppEntity app, + Map partnerNameMap) { + String channelNo = o.getChannelNo(); + String partner = StrUtil.trim(o.getPartnerCode()); + String protocol = o.getPartnerProtocol(); + if (StrUtil.startWithIgnoreCase(channelNo, "APP") && StrUtil.isBlank(partner) && app == null) { + o.setOrderSource("APP"); + o.setOrderSourceLabel("App"); + return; + } + if (StrUtil.startWithIgnoreCase(channelNo, "ADMIN") && StrUtil.isBlank(partner) && app == null) { + o.setOrderSource("ADMIN"); + o.setOrderSourceLabel("后台"); + return; + } + if (StrUtil.startWithIgnoreCase(o.getPlatformNo(), "LOCAL") && StrUtil.isBlank(partner) && app == null) { + o.setOrderSource("LOCAL"); + o.setOrderSourceLabel("本地确认"); + return; + } + if (StrUtil.isNotBlank(partner) || app != null) { + o.setOrderSource("DOWNSTREAM"); + String appName = app == null ? null : StrUtil.blankToDefault(app.getAppName(), app.getAppKey()); + String named = StrUtil.isNotBlank(partner) ? partnerNameMap.get(partner) : null; + if (StrUtil.isBlank(named) && StrUtil.isNotBlank(appName)) { + named = appName; + } + if (StrUtil.isBlank(protocol) && (app != null || "open".equals(o.getPartnerProtocol()))) { + protocol = "open"; + o.setPartnerProtocol("open"); + } + String protocolLabel = StrUtil.isNotBlank(protocol) ? protocolDisplayLabel(protocol) : null; + if (StrUtil.isNotBlank(named)) { + // 名称里已含协议前缀则不再重复拼 + if (StrUtil.isNotBlank(protocolLabel) && !named.contains(protocolLabel) + && !named.startsWith(protocolLabel)) { + o.setOrderSourceLabel(protocolLabel + "·" + named); + } + else { + o.setOrderSourceLabel(named); + } + } + else if (StrUtil.isNotBlank(protocolLabel) && StrUtil.isNotBlank(partner)) { + o.setOrderSourceLabel(protocolLabel + "·" + partner); + } + else if (StrUtil.isNotBlank(partner)) { + o.setOrderSourceLabel(partner); } else { - o.setOrderSource(StrUtil.isBlank(channelNo) ? "-" : "DOWNSTREAM"); + o.setOrderSourceLabel(StrUtil.blankToDefault(protocolLabel, "下游")); } + return; } + if (StrUtil.isNotBlank(channelNo)) { + o.setOrderSource("DOWNSTREAM"); + o.setOrderSourceLabel("下游"); + return; + } + o.setOrderSource("-"); + o.setOrderSourceLabel("-"); + } + + private static String protocolDisplayLabel(String protocol) { + if ("open".equalsIgnoreCase(protocol)) { + return "开放"; + } + return SmHotelDownstreamProtocol.label(protocol); } @Override @@ -406,6 +591,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl local = finishLocalOrder(order, product); + notifyOpenOrderCallback(order); + return local; } Optional gatewayOpt = resolveUpstreamGateway(hotel, order, false); @@ -462,6 +652,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl