酒店,食堂

This commit is contained in:
zjf committed 2026-10-20 11:40:40 +08:00
1 parent 0865a8836d
commit bc2fd545f1
24 files changed
+1664 -96

No files matched your search

@@ -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;
@@ -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());
@@ -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() {
}
}
@@ -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<SmHotelDownstreamAppEntity> {
@TableId(type = IdType.ASSIGN_ID)
@@ -21,12 +21,33 @@ public class SmHotelDownstreamAppEntity extends Model<SmHotelDownstreamAppEntity
private Long tenantId;
@Schema(description = "下游名称")
private String appName;
@Schema(description = "订单来源伙伴编码(空则用 appKey)")
private String partnerCode;
@Schema(description = "appKey(发给对方)")
private String appKey;
@Schema(description = "appSecret(仅本地签名,列表脱敏)")
private String appSecret;
@Schema(description = "对方回调根地址")
private String callbackBaseUrl;
@Schema(description = "订单状态回调路径")
private String callbackOrderPath;
@Schema(description = "资源变更回调路径")
private String callbackResourcePath;
@Schema(description = "1启用回调 0关闭")
private String callbackEnabled;
@Schema(description = "按接口 QPS JSON")
private String rateLimitJson;
@Schema(description = "1启用 0停用")
private String status;
@@ -47,4 +68,9 @@ public class SmHotelDownstreamAppEntity extends Model<SmHotelDownstreamAppEntity
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
/** 列表展示:密钥脱敏(非表字段) */
@TableField(exist = false)
@Schema(description = "脱敏后的密钥")
private String appSecretMasked;
}
@@ -25,7 +25,7 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
private Long downstreamAppId;
@Schema(description = "下游伙伴编码(环启等)")
@Schema(description = "订单来源编码(开放应用/环启/慧订等)")
private String partnerCode;
private Long hotelId;
@@ -134,11 +134,26 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
@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 = "酒店数据来源")
@@ -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<SmHotelUserSearchPrefEntity> {
@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;
}
@@ -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<String> 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<Map<String, Object>> filterMeta() {
@@ -114,7 +132,7 @@ public class SmHotelAppController {
return R.ok(out);
}
@Operation(summary = "上架酒店分页(关键字/区域/星级/评分/价格/设施)")
@Operation(summary = "上架酒店分页(关键字/区域/星级/评分/价格/设施/人数/入住区间)")
@GetMapping("/page")
public R<Page<Map<String, Object>>> page(@ParameterObject Page<SmHotelEntity> 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<Integer> starList = parseIntCsv(starLevels);
List<String> facilityList = parseStrCsv(facilityTags);
var wrapper = Wrappers.<SmHotelEntity>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<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);
}
else {
// 候选过多:回退轻量 EXISTS(不再 join product)
applyBookableHotelFilter(wrapper, stayFrom, stayToExclusive);
applyGuestCapacityFilter(wrapper, needCap);
}
// 起价区间:按产品最低卖价过滤(分页前约束)
if (minPrice != null || maxPrice != null) {
List<Long> priceHitIds = loadHotelIdsByMinPriceRange(minPrice, maxPrice);
@@ -181,6 +225,70 @@ public class SmHotelAppController {
return R.ok(out);
}
@Operation(summary = "读取当前用户酒店搜索习惯")
@GetMapping("/search-pref")
public R<Map<String, Object>> getSearchPref() {
PjcloudUser user = SecurityUtils.getUser();
if (user == null || user.getId() == null) {
return R.failed("请先登录");
}
SmHotelUserSearchPrefEntity row = userSearchPrefMapper.selectOne(Wrappers.<SmHotelUserSearchPrefEntity>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<Map<String, Object>> saveSearchPref(@RequestBody Map<String, Object> 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.<SmHotelUserSearchPrefEntity>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<SmHotelDetailVO> detail(@PathVariable Long id) {
@@ -506,6 +614,146 @@ public class SmHotelAppController {
return map;
}
/**
* 解析列表候选酒店:
* 1) 入住区间内有日历;
* 2) 可选:房型可住人数达标。
* 返回 null 表示候选过多,调用方回退 EXISTS。
*/
private List<Long> 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<Long> bookable = dailyMapper.selectDistinctHotelIdsByStayRange(from, toEx);
if (bookable == null || bookable.isEmpty()) {
return List.of();
}
List<Long> ids = bookable.stream().filter(Objects::nonNull).distinct().toList();
if (needCapPerRoom > 1) {
List<Long> capHotels = roomMapper.selectDistinctHotelIdsByMinCapacity(needCapPerRoom);
if (capHotels == null || capHotels.isEmpty()) {
return List.of();
}
Set<Long> 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<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);
}
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 &gt;= 每间所需人数;
* capacity 为空的房型视为未知容量,仍保留(避免老数据全被滤掉)。
*/
private void applyGuestCapacityFilter(LambdaQueryWrapper<SmHotelEntity> 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<String, Object> defaultSearchPref() {
Map<String, Object> 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<String, Object> toSearchPrefMap(SmHotelUserSearchPrefEntity row) {
Map<String, Object> 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<Long> loadHotelIdsByMinPriceRange(BigDecimal minPrice, BigDecimal maxPrice) {
List<SmHotelProductEntity> products = Db.lambdaQuery(SmHotelProductEntity.class)
@@ -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);
// 位置区域:省/市前缀覆盖下级,并兼容仅有区域名称的老数据
@@ -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<SmHotelDownstreamAppEntity>> page(@ParameterObject Page<SmHotelDownstreamAppEntity> page,
@ParameterObject SmHotelDownstreamAppEntity query) {
return R.ok(appService.pageApps(page, query));
}
@Operation(summary = "开放应用详情")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_view')")
public R<SmHotelDownstreamAppEntity> 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<SmHotelDownstreamAppEntity> create(@RequestBody SmHotelDownstreamAppEntity body) {
return appService.createApp(body);
}
@SysLog("修改下游开放应用")
@Operation(summary = "更新开放应用(不含密钥)")
@PutMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_edit')")
public R<SmHotelDownstreamAppEntity> update(@RequestBody SmHotelDownstreamAppEntity body) {
return appService.updateApp(body);
}
@SysLog("删除下游开放应用")
@Operation(summary = "删除开放应用")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_edit')")
public R<Boolean> remove(@RequestBody Long[] ids) {
return appService.removeApps(ids);
}
@SysLog("轮换下游开放应用密钥")
@Operation(summary = "重新生成 secret(返回明文仅此一次)")
@PostMapping("/rotateSecret/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamApp_edit')")
public R<SmHotelDownstreamAppEntity> rotateSecret(@PathVariable Long id) {
return appService.rotateSecret(id);
}
}
@@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<JSONObject> 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<JSONObject> 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<JSONObject> orderQuery(HttpServletRequest request, @RequestBody String rawBody) {
return guarded(request, rawBody, SmHotelOpenApi.API_ORDER_QUERY,
(app, body) -> openApiService.orderQuery(app, body));
}
private <T> R<T> guarded(HttpServletRequest request, String rawBody, String apiCode, Handler<T> 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<String, Object> 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<T> {
R<T> handle(SmHotelDownstreamAppEntity app, Map<String, Object> body);
}
}
@@ -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<java.util.List<java.util.Map<String, Object>>> orderSourceOptions() {
return R.ok(orderService.listOrderSourceOptions());
}
@Operation(summary = "订单详情(含酒店/产品/日历)")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')")
@@ -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<String, Object> 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<String, Object> 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;
}
}
@@ -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;
}
}
@@ -30,6 +30,18 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper<SmHotelDailyEntity
int physicalDeleteRange(@Param("hotelId") Long hotelId, @Param("fromDate") LocalDate fromDate,
@Param("toExclusive") LocalDate toExclusive);
/**
* 移动端列表:入住半开区间内有日历的酒店(DISTINCT,避免相关子查询扫大表)。
*/
@Select("""
SELECT DISTINCT hotel_id FROM canteen_hotel_daily
WHERE del_flag = '0'
AND stay_date >= #{fromDate}
AND stay_date < #{toExclusive}
""")
List<Long> selectDistinctHotelIdsByStayRange(@Param("fromDate") LocalDate fromDate,
@Param("toExclusive") LocalDate toExclusive);
int insertBatch(@Param("list") List<SmHotelDailyEntity> list);
/**
@@ -24,4 +24,12 @@ public interface SmHotelRoomMapper extends PjcloudBaseMapper<SmHotelRoomEntity>
</script>
""")
List<Map<String, Object>> countGroupByHotelIds(@Param("ids") Collection<Long> ids);
/** 移动端列表:房型可住人数达标(或未填容量)的酒店 */
@Select("""
SELECT DISTINCT hotel_id FROM canteen_hotel_room
WHERE del_flag = '0'
AND (capacity IS NULL OR capacity >= #{minCapacity})
""")
List<Long> selectDistinctHotelIdsByMinCapacity(@Param("minCapacity") int minCapacity);
}
@@ -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<SmHotelUserSearchPrefEntity> {
}
@@ -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<SmHotelDownstreamAppEntity> {
Page<SmHotelDownstreamAppEntity> pageApps(Page<SmHotelDownstreamAppEntity> page, SmHotelDownstreamAppEntity query);
SmHotelDownstreamAppEntity getDetail(Long id);
R<SmHotelDownstreamAppEntity> createApp(SmHotelDownstreamAppEntity entity);
R<SmHotelDownstreamAppEntity> updateApp(SmHotelDownstreamAppEntity entity);
R<Boolean> removeApps(Long[] ids);
/** 重新生成密钥,返回含明文 secret 的对象(仅此一次) */
R<SmHotelDownstreamAppEntity> rotateSecret(Long id);
SmHotelDownstreamAppEntity getEnabledByAppKey(String appKey);
/** 订单来源编码:优先 partnerCode,否则 appKey */
String resolvePartnerCode(SmHotelDownstreamAppEntity app);
}
@@ -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<Map<String, Object>> hotelList(SmHotelDownstreamAppEntity app, Map<String, Object> body);
R<Map<String, Object>> hotelDetail(SmHotelDownstreamAppEntity app, Map<String, Object> body);
R<Map<String, Object>> hotelDaily(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);
R<JSONObject> orderCancel(SmHotelDownstreamAppEntity app, Map<String, Object> body);
R<JSONObject> orderQuery(SmHotelDownstreamAppEntity app, Map<String, Object> body);
}
@@ -17,6 +17,9 @@ public interface SmHotelOrderService {
Page<SmHotelOrderEntity> pageOrders(Page<SmHotelOrderEntity> page, SmHotelOrderEntity query);
/** 订单来源筛选项:渠道下游 + 开放应用(各自显示名称) */
List<Map<String, Object>> listOrderSourceOptions();
/** App「我的订单」:按用户 ID 绑定(channelNo 前缀 APP{userId}) */
Page<SmHotelOrderEntity> pageMyAppOrders(Page<SmHotelOrderEntity> page, PjcloudUser user, String orderStatus);
@@ -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<SmHotelDownstreamAppMapper, SmHotelDownstreamAppEntity>
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<SmHotelDownstreamAppEntity> pageApps(Page<SmHotelDownstreamAppEntity> page,
SmHotelDownstreamAppEntity query) {
Page<SmHotelDownstreamAppEntity> result = baseMapper.selectPage(page,
Wrappers.<SmHotelDownstreamAppEntity>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<SmHotelDownstreamAppEntity> 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<SmHotelDownstreamAppEntity> 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<Boolean> removeApps(Long[] ids) {
if (ids == null || ids.length == 0) {
return R.failed("请选择要删除的应用");
}
List<Long> list = Arrays.asList(ids);
baseMapper.deleteByIds(list);
return R.ok(Boolean.TRUE);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<SmHotelDownstreamAppEntity> 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.<SmHotelDownstreamAppEntity>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.<SmHotelDownstreamAppEntity>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);
}
}
@@ -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<String, Object> create(String partnerCode, JSONObject data) {
Map<String, Object> 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<Boolean> 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<String, Object> 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<Boolean> 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<Boolean> 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<String, Object> 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<String, Object> buildCreateData(SmHotelOrderEntity order) {
Map<String, Object> 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;
}
@@ -904,7 +904,21 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
for (String code : codes) {
Map<String, Object> 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);
@@ -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<Map<String, Object>> hotelList(SmHotelDownstreamAppEntity app, Map<String, Object> 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<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));
List<Map<String, Object>> records = new ArrayList<>();
for (SmHotelEntity h : page.getRecords()) {
records.add(toHotelBrief(h));
}
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());
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")));
if (StrUtil.isBlank(hotelCode)) {
return R.failed("hotelCode 不能为空");
}
SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, hotelCode)
.last("limit 1"));
if (hotel == null) {
return R.failed("酒店不存在");
}
Map<String, Object> data = toHotelBrief(hotel);
data.put("coverImage", hotel.getCoverImage());
data.put("carouselImages", hotel.getCarouselImages());
data.put("intro", hotel.getIntro());
data.put("starLevel", hotel.getStarLevel());
List<SmHotelRoomEntity> rooms = roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotel.getId())
.orderByAsc(SmHotelRoomEntity::getRoomTypeCode));
List<Map<String, Object>> roomList = new ArrayList<>();
for (SmHotelRoomEntity r : rooms) {
Map<String, Object> 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<SmHotelProductEntity> products = productMapper.selectList(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotel.getId())
.orderByAsc(SmHotelProductEntity::getProductCode));
List<Map<String, Object>> productList = new ArrayList<>();
for (SmHotelProductEntity p : products) {
Map<String, Object> 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<Map<String, Object>> hotelDaily(SmHotelDownstreamAppEntity app, Map<String, Object> 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.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, hotelCode)
.last("limit 1"));
if (hotel == null) {
return R.failed("酒店不存在");
}
List<SmHotelDailyEntity> dailies = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>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<Map<String, Object>> list = new ArrayList<>();
for (SmHotelDailyEntity d : dailies) {
Map<String, Object> 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<String, Object> data = new LinkedHashMap<>(4);
data.put("hotelCode", hotelCode);
data.put("list", list);
return R.ok(data);
}
@Override
public R<Map<String, Object>> orderCheck(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
Map<String, Object> 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<JSONObject> orderCreate(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
// 保证订单来源写入伙伴编码
R<JSONObject> r = hotelOrderService.createFromDownstream(app.getAppKey(), body);
return r;
}
@Override
public R<JSONObject> orderCancel(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
R<JSONObject> r = hotelOrderService.cancelFromDownstream(app.getAppKey(), body);
return r;
}
@Override
public R<JSONObject> orderQuery(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
return hotelOrderService.queryFromDownstream(app.getAppKey(), body);
}
private Map<String, Object> toHotelBrief(SmHotelEntity h) {
Map<String, Object> 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;
}
}
}
@@ -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<SmHotelOrderMapper, SmH
private final RemoterThirdAppPlusPayService remoterThirdAppPlusPayService;
private final DownstreamOpenCallbackClient openCallbackClient;
private final SmHotelRoomMapper roomMapper;
@Override
@@ -106,6 +110,10 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
.like(StrUtil.isNotBlank(query.getChannelNo()), SmHotelOrderEntity::getChannelNo, query.getChannelNo())
.like(StrUtil.isNotBlank(query.getPlatformNo()), SmHotelOrderEntity::getPlatformNo,
query.getPlatformNo())
.eq(StrUtil.isNotBlank(query.getPartnerCode()), SmHotelOrderEntity::getPartnerCode,
query.getPartnerCode())
.eq(query.getDownstreamAppId() != null, SmHotelOrderEntity::getDownstreamAppId,
query.getDownstreamAppId())
.eq(StrUtil.isNotBlank(query.getOrderStatus()), SmHotelOrderEntity::getOrderStatus,
query.getOrderStatus())
.eq(StrUtil.isNotBlank(query.getBookStatus()), SmHotelOrderEntity::getBookStatus, query.getBookStatus())
@@ -123,6 +131,50 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return result;
}
@Override
public List<Map<String, Object>> listOrderSourceOptions() {
List<Map<String, Object>> options = new ArrayList<>();
java.util.LinkedHashSet<String> 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<String, Object> row = new LinkedHashMap<>(6);
row.put("partnerCode", code);
row.put("protocol", protocol);
row.put("label", label);
row.put("type", "channel");
options.add(row);
}
List<SmHotelDownstreamAppEntity> apps = appMapper.selectList(Wrappers.<SmHotelDownstreamAppEntity>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<String, Object> 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<SmHotelOrderEntity> pageMyAppOrders(Page<SmHotelOrderEntity> page, PjcloudUser user,
String orderStatus) {
@@ -154,7 +206,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
/**
* 分页列表展示字段:酒店名称、数据来源(本地/上游)、channelNo 推断下单来源。
* 分页列表展示字段:酒店名称、数据来源、订单来源(App/后台/分销伙伴协议+编码/下游应用)。
* 酒店数据来源 ≠ 订单销售来源:同一上游(如飞猪)可被多家下游售出,靠 partnerCode / downstreamAppId 区分。
*/
private void fillOrderListDisplay(List<SmHotelOrderEntity> records) {
if (records == null || records.isEmpty()) {
@@ -188,6 +241,51 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
}
}
// 渠道对接配置:协议 + 展示名(优先备注)
Map<String, String> partnerProtocolMap = new HashMap<>();
Map<String, String> 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<Long> appIds = records.stream()
.map(SmHotelOrderEntity::getDownstreamAppId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Long, SmHotelDownstreamAppEntity> appById = new HashMap<>();
if (!appIds.isEmpty()) {
for (SmHotelDownstreamAppEntity app : appMapper.selectByIds(appIds)) {
appById.put(app.getId(), app);
indexOpenAppNames(partnerNameMap, partnerProtocolMap, app);
}
}
List<String> 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<SmHotelDownstreamAppEntity> apps = appMapper.selectList(Wrappers.<SmHotelDownstreamAppEntity>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<SmHotelOrderMapper, SmH
o.setHotelName(hotel.getHotelName());
o.setHotelDataSource(upstreamGatewayRegistry.resolveChannelKey(hotel.getSheetName()));
}
String channelNo = o.getChannelNo();
if (StrUtil.startWithIgnoreCase(channelNo, "APP")) {
o.setOrderSource("APP");
SmHotelDownstreamAppEntity app = o.getDownstreamAppId() == null ? null
: appById.get(o.getDownstreamAppId());
if (app != null) {
o.setDownstreamAppName(StrUtil.blankToDefault(app.getAppName(), app.getAppKey()));
}
else if (StrUtil.startWithIgnoreCase(channelNo, "ADMIN")) {
o.setOrderSource("ADMIN");
String partner = StrUtil.trim(o.getPartnerCode());
if (StrUtil.isNotBlank(partner)) {
o.setPartnerProtocol(partnerProtocolMap.get(partner));
}
else if (StrUtil.startWithIgnoreCase(o.getPlatformNo(), "LOCAL")) {
o.setOrderSource("LOCAL");
else if (app != null) {
o.setPartnerProtocol("open");
}
fillOrderSourceFields(o, app, partnerNameMap);
}
}
private static void indexOpenAppNames(Map<String, String> partnerNameMap,
Map<String, String> 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<String, String> 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<SmHotelOrderMapper, SmH
SmHotelOrderEntity order = new SmHotelOrderEntity();
order.setDownstreamAppId(app.getId());
// 订单销售来源:开放应用 partnerCode(空则 appKey),与环启/慧订分销伙伴同一列
order.setPartnerCode(StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()),
StrUtil.blankToDefault(app.getAppKey(), app.getAppName())));
order.setHotelId(hotel.getId());
order.setChannelNo(channelNo);
order.setGroupCode(groupCode);
@@ -445,7 +633,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
// 本地酒店:扣本地日历库存,不走上游
if (localHotel) {
return finishLocalOrder(order, product);
R<JSONObject> local = finishLocalOrder(order, product);
notifyOpenOrderCallback(order);
return local;
}
Optional<HotelUpstreamOrderGateway> gatewayOpt = resolveUpstreamGateway(hotel, order, false);
@@ -462,6 +652,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg(StrUtil.sub(availMsg, 0, 500));
baseMapper.updateById(order);
notifyOpenOrderCallback(order);
return R.failed(availMsg);
}
}
@@ -474,6 +665,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
HotelUpstreamOrderResult call = invokeCreateOrder(gateway, order, upstreamBody, "CREATE");
applyCreateResult(order, call, upstreamBody);
baseMapper.updateById(order);
notifyOpenOrderCallback(order);
JSONObject out = buildOut(order);
if ("UPSTREAM_FAIL".equals(order.getOrderStatus()) && !COMP_WAITING.equals(order.getCompensateStatus())) {
return R.failed(StrUtil.blankToDefault(order.getFailMsg(), "上游下单失败"));
@@ -2010,6 +2202,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
baseMapper.updateById(order);
notifyOpenOrderCallback(order);
return R.ok(buildOut(order), "本地订单已取消并回补库存");
}
if (StrUtil.isBlank(order.getPlatformNo())) {
@@ -2019,7 +2212,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (cancelR == null || !cancelR.isOk()) {
return R.failed(cancelR == null ? "上游取消失败" : cancelR.getMsg());
}
return R.ok(buildOut(baseMapper.selectById(order.getId())));
SmHotelOrderEntity latest = baseMapper.selectById(order.getId());
notifyOpenOrderCallback(latest);
return R.ok(buildOut(latest));
}
@Override
@@ -2392,6 +2587,14 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return dailyPriceList;
}
/** 开放应用订单状态回调(未配置回调则跳过) */
private void notifyOpenOrderCallback(SmHotelOrderEntity order) {
if (order == null || order.getDownstreamAppId() == null || openCallbackClient == null) {
return;
}
openCallbackClient.notifyOrderStatusAsync(order.getDownstreamAppId(), order);
}
private SmHotelDownstreamAppEntity requireApp(String appKey) {
if (StrUtil.isBlank(appKey)) {
throw new IllegalArgumentException("缺少 appKey");