From 61ce4eda24f4c98b8b67183f5d4650508658e206 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Thu, 12 Nov 2026 19:17:18 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feign/RemoteSmHotelFxInviteService.java | 28 ++ .../constant/SmHotelDownstreamProtocol.java | 24 +- .../entity/SmHotelFxAgentEntity.java | 9 + .../entity/SmHotelFxHotelApplyEntity.java | 82 +++++ .../vo/SmHotelFxInvitePreviewVO.java | 30 ++ .../controller/SmHotelFxController.java | 136 +++++++- .../SmHotelFxInviteInnerController.java | 42 +++ .../SmHotelNiceOrderController.java | 90 ++++++ .../SmHotelShengnuoOrderController.java | 3 +- .../DownstreamHotelAdapterRegistry.java | 5 + .../downstream/DownstreamInboundAuth.java | 2 +- .../ShengnuoDownstreamHotelAdapter.java | 3 +- .../mapper/SmHotelDailyMapper.java | 4 + .../mapper/SmHotelFxHotelApplyMapper.java | 9 + .../service/SmHotelFxAgentService.java | 12 + .../service/SmHotelFxCommissionService.java | 10 + .../service/SmHotelFxHotelApplyService.java | 26 ++ ...otelDownstreamInboundRetryServiceImpl.java | 6 +- .../impl/SmHotelFxAgentServiceImpl.java | 262 ++++++++++++++- .../impl/SmHotelFxCommissionServiceImpl.java | 253 ++++++++++++++- .../impl/SmHotelFxHotelApplyServiceImpl.java | 302 ++++++++++++++++++ .../impl/SmHotelFxWithdrawServiceImpl.java | 6 +- .../service/impl/SmHotelMidServiceImpl.java | 143 ++++++--- .../admin/api/vo/SysDeptInvitePreviewVO.java | 12 + pjcloud-upms/pjcloud-upms-biz/pom.xml | 7 + .../impl/SysDeptInviteServiceImpl.java | 275 ++++++++++++---- 26 files changed, 1639 insertions(+), 142 deletions(-) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelFxInviteService.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxHotelApplyEntity.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelFxInvitePreviewVO.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxInviteInnerController.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelNiceOrderController.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelFxHotelApplyMapper.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxHotelApplyService.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxHotelApplyServiceImpl.java diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelFxInviteService.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelFxInviteService.java new file mode 100644 index 000000000..faf8a3f68 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelFxInviteService.java @@ -0,0 +1,28 @@ +package com.bsd.pjcloud.supermarket.api.feign; + +import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; +import com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 酒店分销邀请:UPMS 注册页识别分销邀请码后回调挂靠分销员。 + */ +@FeignClient(contextId = "remoteSmHotelFxInviteService", value = ServiceNameConstants.SUPERMARKET_SERVICE) +public interface RemoteSmHotelFxInviteService { + + @NoToken + @GetMapping("/inner/smHotelFx/invite/preview") + R preview(@RequestParam("inviteCode") String inviteCode); + + @NoToken + @PostMapping("/inner/smHotelFx/invite/bindAgent") + R bindAgent(@RequestParam("inviteCode") String inviteCode, @RequestParam("userId") Long userId, + @RequestParam(value = "username", required = false) String username, + @RequestParam(value = "realName", required = false) String realName, + @RequestParam(value = "mobile", required = false) String mobile); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java index 0e5262e6f..1a0dca25b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java @@ -17,9 +17,12 @@ public final class SmHotelDownstreamProtocol { /** 慧订:docs/xiayou/shenzhen(静态 + hoteldatepush) */ public static final String HUIDING = "huiding"; - /** 圣诺/奈斯:docs/xiayou/shengnuo(getInfo + plan + price + close + orderChange) */ + /** 圣诺:docs/xiayou/shengnuo(getInfo + plan + price + close + orderChange) */ public static final String SHENGNUO = "shengnuo"; + /** 奈斯:与圣诺同接口形态,独立伙伴配置与入站路径 /third/party/nice/order/* */ + public static final String NICE = "nice"; + /** @deprecated 历史误用名,normalize 后等价 {@link #HUANQI} */ @Deprecated public static final String XIAYOU = "xiayou"; @@ -43,9 +46,12 @@ public final class SmHotelDownstreamProtocol { if (SHENZHEN.equals(p)) { return HUIDING; } - if (NS.equals(p) || "nais".equals(p) || "nice".equals(p)) { + if (NS.equals(p)) { return SHENGNUO; } + if ("nais".equals(p)) { + return NICE; + } return p; } @@ -55,6 +61,7 @@ public final class SmHotelDownstreamProtocol { set.add(HUANQI); set.add(HUIDING); set.add(SHENGNUO); + set.add(NICE); return set; } @@ -70,8 +77,14 @@ public final class SmHotelDownstreamProtocol { return HUIDING.equals(normalize(protocol)); } + /** 圣诺或奈斯(同套推送/验签逻辑) */ public static boolean isShengnuo(String protocol) { - return SHENGNUO.equals(normalize(protocol)); + String p = normalize(protocol); + return SHENGNUO.equals(p) || NICE.equals(p); + } + + public static boolean isNice(String protocol) { + return NICE.equals(normalize(protocol)); } public static String label(String protocol) { @@ -83,7 +96,10 @@ public final class SmHotelDownstreamProtocol { return "慧订"; } if (SHENGNUO.equals(p)) { - return "圣诺/高德"; + return "圣诺"; + } + if (NICE.equals(p)) { + return "奈斯"; } return p; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxAgentEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxAgentEntity.java index 203fb3c30..10b86eb1e 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxAgentEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxAgentEntity.java @@ -56,6 +56,15 @@ public class SmHotelFxAgentEntity extends Model { private String remark; + @Schema(description = "收款方式 BANK/ALIPAY/WECHAT/OTHER") + private String payMethod; + + @Schema(description = "收款账号") + private String payAccount; + + @Schema(description = "开户名/收款户名") + private String payAccountName; + @TableField(fill = FieldFill.INSERT) private String createBy; diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxHotelApplyEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxHotelApplyEntity.java new file mode 100644 index 000000000..85eaa9048 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelFxHotelApplyEntity.java @@ -0,0 +1,82 @@ +package com.bsd.pjcloud.supermarket.entity; + +import com.baomidou.mybatisplus.annotation.*; +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.LocalDateTime; + +@Data +@TenantTable +@TableName("canteen_hotel_fx_hotel_apply") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店分销-分销员提报酒店") +public class SmHotelFxHotelApplyEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + private Long agentId; + + private Long userId; + + private String hotelName; + + private String hotelCode; + + private String district; + + private Long districtAdcode; + + private String address; + + private String contactName; + + private String contactPhone; + + private String remark; + + @Schema(description = "提报扩展 JSON(licenseUrl 等)") + private String hotelJson; + + @Schema(description = "PENDING/APPROVED/REJECTED/CANCELLED") + private String status; + + private Long hotelId; + + @Schema(description = "审核备注;通过时填酒店账号密码等,分销员可见") + private String auditRemark; + + private String auditBy; + + private LocalDateTime auditTime; + + @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; + + @TableField(exist = false) + private String agentName; + + /** 营业执照图片 URL(来自 hotel_json.licenseUrl,非表字段) */ + @TableField(exist = false) + @Schema(description = "营业执照图片") + private String licenseUrl; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelFxInvitePreviewVO.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelFxInvitePreviewVO.java new file mode 100644 index 000000000..c5e41568e --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelFxInvitePreviewVO.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.supermarket.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 酒店分销邀请码预览(供 UPMS 注册页复用同一邀请码输入框)。 + */ +@Data +@Schema(description = "酒店分销邀请预览") +public class SmHotelFxInvitePreviewVO { + + private String inviteCode; + + private Long parentAgentId; + + private Long parentUserId; + + /** 新用户落入邀请人所属部门 */ + private Long deptId; + + private String parentName; + + private Integer parentLevel; + + /** 新分销员层级:1~3 邀 → 4;4 邀 → 5 */ + private Integer childLevel; + + private String hint; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxController.java index 35fd669b2..b2d40a7a3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxController.java @@ -1,5 +1,6 @@ package com.bsd.pjcloud.supermarket.controller; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; @@ -7,6 +8,7 @@ import com.bsd.pjcloud.supermarket.entity.SmHotelFxAgentEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelFxCommissionEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelFxConfigEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelFxExitEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelApplyEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelBindEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelFxL1ApplyEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelFxWithdrawEntity; @@ -14,6 +16,7 @@ import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService; import com.bsd.pjcloud.supermarket.service.SmHotelFxCommissionService; import com.bsd.pjcloud.supermarket.service.SmHotelFxConfigService; import com.bsd.pjcloud.supermarket.service.SmHotelFxExitService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxHotelApplyService; import com.bsd.pjcloud.supermarket.service.SmHotelFxL1ApplyService; import com.bsd.pjcloud.supermarket.service.SmHotelFxWithdrawService; import io.swagger.v3.oas.annotations.Operation; @@ -22,6 +25,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; import java.util.Map; /** @@ -45,6 +49,8 @@ public class SmHotelFxController { private final SmHotelFxExitService exitService; + private final SmHotelFxHotelApplyService hotelApplyService; + @Operation(summary = "分销配置") @GetMapping("/config") @PreAuthorize("@pms.hasPermission('supermarket_smHotelFx_view')") @@ -118,6 +124,28 @@ public class SmHotelFxController { return R.ok(agentService.pageBinds(page, agentId, hotelId)); } + @Operation(summary = "按酒店ID批量查已绑定分销员(审核选店)") + @GetMapping("/bind/byHotels") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxAgent_view') || @pms.hasPermission('supermarket_smHotelFxHotelApply_view') || @pms.hasPermission('supermarket_smHotelFxHotelApply_audit')") + public R> bindsByHotels(@RequestParam String hotelIds) { + java.util.List ids = new java.util.ArrayList<>(); + if (cn.hutool.core.util.StrUtil.isNotBlank(hotelIds)) { + for (String p : hotelIds.split(",")) { + String t = p.trim(); + if (cn.hutool.core.util.StrUtil.isBlank(t)) { + continue; + } + try { + ids.add(Long.parseLong(t)); + } + catch (NumberFormatException ignored) { + // skip bad token + } + } + } + return R.ok(agentService.listBoundByHotelIds(ids)); + } + @Operation(summary = "佣金明细分页(管理端)") @GetMapping("/commission/page") @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxCommission_view')") @@ -195,7 +223,11 @@ public class SmHotelFxController { @Operation(summary = "我的佣金汇总(App)") @GetMapping("/app/mySummary") public R> mySummary() { - return commissionService.mySummary(); + R> r = commissionService.mySummary(); + if (r != null && r.isOk() && r.getData() != null) { + r.getData().put("pendingApplyCount", hotelApplyService.countPendingMine()); + } + return r; } @Operation(summary = "我的佣金明细(App)") @@ -205,6 +237,22 @@ public class SmHotelFxController { return R.ok(commissionService.pageMine(page, status)); } + @Operation(summary = "下线绩效汇总(App:直接下级佣金归本人,上级只读)") + @GetMapping("/app/downlinePerf") + public R> downlinePerf() { + return commissionService.downlinePerf(); + } + + @SysLog("分销模拟下单") + @Operation(summary = "模拟下单记佣(管理端联调:酒店须已绑定分销员)") + @PostMapping("/mockOrder") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFx_edit')") + public R> mockOrder(@RequestParam Long hotelId, + @RequestParam(required = false) BigDecimal costAmount, + @RequestParam(required = false, defaultValue = "false") Boolean lockNow) { + return commissionService.mockOrderForBoundHotel(hotelId, costAmount, lockNow); + } + @Operation(summary = "申请一级(App)") @PostMapping("/app/l1/apply") public R appL1Apply(@RequestParam String cityAdcode, @@ -238,4 +286,90 @@ public class SmHotelFxController { public R appExitApply(@RequestParam(required = false) String reason) { return exitService.applyMine(reason); } + + @Operation(summary = "酒店提报分页(管理端)") + @GetMapping("/hotelApply/page") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxHotelApply_view')") + public R> hotelApplyPage(Page page, + SmHotelFxHotelApplyEntity query) { + return R.ok(hotelApplyService.pageAdmin(page, query)); + } + + @SysLog("审核分销酒店提报") + @Operation(summary = "审核酒店提报(通过须选已完善酒店 + 备注账号密码)") + @PostMapping("/hotelApply/audit") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelFxHotelApply_audit')") + public R hotelApplyAudit(@RequestParam Long id, @RequestParam boolean approved, + @RequestParam(required = false) String auditRemark, + @RequestParam(required = false) Long hotelId) { + return hotelApplyService.audit(id, approved, auditRemark, hotelId); + } + + @Operation(summary = "我的酒店提报(App)") + @GetMapping("/app/hotelApply/page") + public R> appHotelApplyPage(Page page, + @RequestParam(required = false) String status) { + return R.ok(hotelApplyService.pageMine(page, status)); + } + + @Operation(summary = "我的提报详情(App,编辑回填)") + @GetMapping("/app/hotelApply/{id}") + public R appHotelApplyDetail(@PathVariable Long id) { + return hotelApplyService.getMine(id); + } + + @Operation(summary = "新增/重提酒店(App:名称+地址+营业执照;传 id 可编辑待审/驳回)") + @PostMapping("/app/hotelApply") + public R appHotelApply(@RequestBody Map body) { + SmHotelFxHotelApplyEntity row = new SmHotelFxHotelApplyEntity(); + if (body != null) { + Object idObj = body.get("id"); + if (idObj != null && StrUtil.isNotBlank(String.valueOf(idObj))) { + try { + row.setId(Long.valueOf(String.valueOf(idObj))); + } + catch (Exception ignored) { + // ignore bad id + } + } + Object json = body.get("hotelJson"); + if (json instanceof String) { + row.setHotelJson((String) json); + } + else if (json != null) { + row.setHotelJson(cn.hutool.json.JSONUtil.toJsonStr(json)); + } + else { + Map copy = new java.util.LinkedHashMap<>(body); + copy.remove("id"); + row.setHotelJson(cn.hutool.json.JSONUtil.toJsonStr(copy)); + } + Object name = body.get("hotelName"); + if (name != null) { + row.setHotelName(String.valueOf(name)); + } + Object address = body.get("address"); + if (address != null) { + row.setAddress(String.valueOf(address)); + } + Object license = body.get("licenseUrl"); + if (license == null) { + license = body.get("businessLicense"); + } + if (license != null) { + row.setLicenseUrl(String.valueOf(license)); + } + Object remark = body.get("remark"); + if (remark != null) { + row.setRemark(String.valueOf(remark)); + } + } + return hotelApplyService.applyMine(row); + } + + @Operation(summary = "我的绑定酒店(App)") + @GetMapping("/app/myHotels") + public R> appMyHotels(Page page) { + return R.ok(agentService.pageMineHotels(page)); + } } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxInviteInnerController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxInviteInnerController.java new file mode 100644 index 000000000..4ea61a308 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelFxInviteInnerController.java @@ -0,0 +1,42 @@ +package com.bsd.pjcloud.supermarket.controller; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService; +import com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 酒店分销邀请内部接口:供 UPMS 邀请注册识别分销邀请码。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/inner/smHotelFx/invite") +@Tag(name = "酒店分销邀请-内部") +public class SmHotelFxInviteInnerController { + + private final SmHotelFxAgentService agentService; + + @Inner + @Operation(summary = "分销邀请码预览") + @GetMapping("/preview") + public R preview(@RequestParam String inviteCode) { + return agentService.previewInvite(inviteCode); + } + + @Inner + @Operation(summary = "注册后挂靠分销员") + @PostMapping("/bindAgent") + public R bindAgent(@RequestParam String inviteCode, @RequestParam Long userId, + @RequestParam(required = false) String username, @RequestParam(required = false) String realName, + @RequestParam(required = false) String mobile) { + return agentService.createAgentByInvite(inviteCode, userId, username, realName, mobile); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelNiceOrderController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelNiceOrderController.java new file mode 100644 index 000000000..7774f0b23 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelNiceOrderController.java @@ -0,0 +1,90 @@ +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.security.annotation.Inner; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAccessLog; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAuth; +import com.bsd.pjcloud.supermarket.service.SmHotelShengnuoOrderService; +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.LinkedHashMap; +import java.util.Map; +import java.util.function.BiFunction; + +/** + * 奈斯订单入站(与圣诺同协议、同验签;独立回调路径,伙伴 appid/secret 在「渠道对接配置」各自维护): + *
+ * POST /third/party/nice/order/createOrder
+ * POST /third/party/nice/order/orderCancel
+ * POST /third/party/nice/order/orderConfirmTimeOut
+ * 
+ * 网关完整路径示例:https://域名/api/supermarket/third/party/nice/order/createOrder + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/third/party/nice/order") +@Tag(name = "奈斯酒店订单对接") +public class SmHotelNiceOrderController { + + private final DownstreamInboundAuth inboundAuth; + + private final SmHotelShengnuoOrderService shengnuoOrderService; + + private final DownstreamInboundAccessLog inboundAccessLog; + + @Inner(value = false) + @Operation(summary = "奈斯推送下单") + @PostMapping("/createOrder") + public Map createOrder(HttpServletRequest request, @RequestBody String rawBody) { + return handle(request, "inbound_create", rawBody, shengnuoOrderService::createOrder); + } + + @Inner(value = false) + @Operation(summary = "奈斯推送取消") + @PostMapping("/orderCancel") + public Map orderCancel(HttpServletRequest request, @RequestBody String rawBody) { + return handle(request, "inbound_cancel", rawBody, shengnuoOrderService::orderCancel); + } + + @Inner(value = false) + @Operation(summary = "奈斯确认超时提醒") + @PostMapping("/orderConfirmTimeOut") + public Map orderConfirmTimeOut(HttpServletRequest request, @RequestBody String rawBody) { + return handle(request, "inbound_timeout", rawBody, shengnuoOrderService::orderConfirmTimeOut); + } + + private Map handle(HttpServletRequest request, String action, String rawBody, + BiFunction> fn) { + long t0 = System.currentTimeMillis(); + String partner = null; + Map resp; + try { + partner = inboundAuth.verifyForShengnuo(rawBody); + JSONObject body = JSONUtil.parseObj(rawBody); + resp = fn.apply(partner, body); + } + catch (Exception e) { + log.warn("|奈斯订单|{}|失败|{}", action, e.getMessage()); + resp = new LinkedHashMap<>(6); + resp.put("code", 1); + resp.put("success", false); + resp.put("msg", StrUtil.blankToDefault(e.getMessage(), "处理失败")); + resp.put("status", "error"); + resp.put("data", Map.of()); + } + inboundAccessLog.record(partner, action, request.getRequestURI(), rawBody, resp, + System.currentTimeMillis() - t0); + return resp; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelShengnuoOrderController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelShengnuoOrderController.java index a6c57a2ff..9cfe575e5 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelShengnuoOrderController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelShengnuoOrderController.java @@ -22,12 +22,13 @@ import java.util.Map; import java.util.function.BiFunction; /** - * 圣诺/奈斯订单入站(供应商回调地址,给对方配置): + * 圣诺订单入站(供应商回调地址,给对方配置): *
  * POST /third/party/shengnuo/order/createOrder
  * POST /third/party/shengnuo/order/orderCancel
  * POST /third/party/shengnuo/order/orderConfirmTimeOut
  * 
+ * 奈斯同协议另见 {@link SmHotelNiceOrderController}(/third/party/nice/order/*)。 * 网关完整路径示例:https://域名/api/supermarket/third/party/shengnuo/order/createOrder */ @Slf4j diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelAdapterRegistry.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelAdapterRegistry.java index 882ee6182..95426ec37 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelAdapterRegistry.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelAdapterRegistry.java @@ -28,6 +28,11 @@ public class DownstreamHotelAdapterRegistry { byProtocol.put(key, adapter); } } + // 奈斯与圣诺同接口:复用圣诺适配器,避免再声明继承 Bean 造成按类型注入冲突 + DownstreamHotelAdapter shengnuo = byProtocol.get(SmHotelDownstreamProtocol.SHENGNUO); + if (shengnuo != null && !byProtocol.containsKey(SmHotelDownstreamProtocol.NICE)) { + byProtocol.put(SmHotelDownstreamProtocol.NICE, shengnuo); + } } public DownstreamHotelAdapter require(String protocol) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java index 168b03210..271460f88 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java @@ -441,7 +441,7 @@ public class DownstreamInboundAuth { } } if (StrUtil.isBlank(partner) || StrUtil.isBlank(secret)) { - throw new CheckedException("appid 未匹配到启用的圣诺下游配置"); + throw new CheckedException("appid 未匹配到启用的圣诺或奈斯下游配置"); } if (!ShengnuoSignHelper.matches(appId, secret, template, sign)) { throw new CheckedException("签名错误"); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamHotelAdapter.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamHotelAdapter.java index c18da6610..c58e80c0b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamHotelAdapter.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoDownstreamHotelAdapter.java @@ -20,7 +20,8 @@ import java.util.LinkedHashMap; import java.util.Map; /** - * 圣诺/奈斯下游:getInfo + pushHotelPlan + pushHotelPrice + pushHotelClose + orderChange。 + * 圣诺下游:getInfo + pushHotelPlan + pushHotelPrice + pushHotelClose + orderChange。 + * 奈斯协议复用本适配器(见 {@link com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelAdapterRegistry})。 * 签名 md5('ns'+appid+secretKey+template);成功 code=0。 */ @Slf4j 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 fd0ad8aa5..653631302 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,10 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxAgentService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxAgentService.java index 1b1748130..acecbb0e7 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxAgentService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxAgentService.java @@ -23,10 +23,22 @@ public interface SmHotelFxAgentService extends IService { Page pageBinds(Page page, Long agentId, Long hotelId); + /** 批量查酒店当前 BOUND 绑定(审核选店用) */ + java.util.List listBoundByHotelIds(java.util.Collection hotelIds); + + /** App:当前分销员已绑定酒店 */ + Page pageMineHotels(Page page); + SmHotelFxAgentEntity getByUserId(Long userId); SmHotelFxAgentEntity getBoundAgentByHotelId(Long hotelId); + /** 分销邀请码预览;无效返回 failed */ + R previewInvite(String inviteCode); + + /** 注册成功后按邀请码创建分销员并挂上级 */ + R createAgentByInvite(String inviteCode, Long userId, String username, String realName, String mobile); + /** 锁定佣金后:累加终身晋升分,并尝试 4→3 / 3→2 自动晋升 */ void onPromoteBaseLocked(Long ownerAgentId, BigDecimal promoteBaseAmount); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxCommissionService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxCommissionService.java index 60d917793..586cb8ec7 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxCommissionService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxCommissionService.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.supermarket.entity.SmHotelFxCommissionEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity; +import java.math.BigDecimal; import java.util.Map; /** @@ -27,5 +28,14 @@ public interface SmHotelFxCommissionService extends IService> mySummary(); + /** App:直接下级绩效汇总(佣金归下级本人,上级仅查看) */ + R> downlinePerf(); + Page pageMine(Page page, String status); + + /** + * 管理端联调:对已绑定分销员的酒店模拟一笔订单并记预计佣金。 + * lockNow=true 时退房日设为昨天并立即锁定,便于测提现链路。 + */ + R> mockOrderForBoundHotel(Long hotelId, BigDecimal costAmount, Boolean lockNow); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxHotelApplyService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxHotelApplyService.java new file mode 100644 index 000000000..60f7eef4b --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelFxHotelApplyService.java @@ -0,0 +1,26 @@ +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.SmHotelFxHotelApplyEntity; + +public interface SmHotelFxHotelApplyService extends IService { + + Page pageAdmin(Page page, SmHotelFxHotelApplyEntity query); + + Page pageMine(Page page, String status); + + R applyMine(SmHotelFxHotelApplyEntity body); + + /** App:我的提报详情(编辑回填) */ + R getMine(Long id); + + /** 当前分销员待审提报数 */ + long countPendingMine(); + + /** + * 审核。通过时必须传 hotelId(后台已完善的酒店)和 auditRemark(含账号密码,分销员可见)。 + */ + R audit(Long id, boolean approved, String auditRemark, Long hotelId); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamInboundRetryServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamInboundRetryServiceImpl.java index ddfc19628..e313b4938 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamInboundRetryServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamInboundRetryServiceImpl.java @@ -59,7 +59,8 @@ public class SmHotelDownstreamInboundRetryServiceImpl implements SmHotelDownstre long t0 = System.currentTimeMillis(); Map resp; try { - if (SmHotelDownstreamProtocol.isShengnuo(protocol) || urlContains(url, "shengnuo")) { + if (SmHotelDownstreamProtocol.isShengnuo(protocol) || urlContains(url, "shengnuo") + || urlContains(url, "/third/party/nice/")) { resp = replayShengnuo(partner, action, row.getRequestBody()); if (StrUtil.isBlank(partner)) { partner = peekShengnuoPartner(row.getRequestBody()); @@ -174,7 +175,8 @@ public class SmHotelDownstreamInboundRetryServiceImpl implements SmHotelDownstre return SmHotelDownstreamProtocol.normalize(p.getProtocol()); } } - if (urlContains(url, "shengnuo") || "inbound_timeout".equals(action)) { + if (urlContains(url, "shengnuo") || urlContains(url, "/third/party/nice/") + || "inbound_timeout".equals(action)) { return SmHotelDownstreamProtocol.SHENGNUO; } if (urlContains(url, "huiding") || "inbound_notify".equals(action)) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxAgentServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxAgentServiceImpl.java index 3eb67fd20..20acd8f29 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxAgentServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxAgentServiceImpl.java @@ -7,7 +7,11 @@ import cn.hutool.json.JSONUtil; 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.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.supermarket.dto.SmHotelFxLevelRuleDTO; import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelFxAgentEntity; @@ -25,6 +29,8 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -44,6 +50,9 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl pageAgents(Page page, SmHotelFxAgentEntity query) { Page result = page(page, Wrappers.lambdaQuery() @@ -62,6 +71,8 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl parentIds = list.stream().map(SmHotelFxAgentEntity::getParentId).filter(Objects::nonNull).distinct() .toList(); Map parentName = Map.of(); @@ -82,6 +93,74 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl agents, boolean persist) { + if (CollUtil.isEmpty(agents)) { + return; + } + List userIds = agents.stream().map(SmHotelFxAgentEntity::getUserId).filter(Objects::nonNull).distinct() + .toList(); + if (userIds.isEmpty()) { + return; + } + Map userMap = loadUserMap(userIds); + if (userMap.isEmpty()) { + return; + } + List dirty = new ArrayList<>(); + for (SmHotelFxAgentEntity a : agents) { + SysUser u = a.getUserId() == null ? null : userMap.get(a.getUserId()); + if (u == null) { + continue; + } + String username = StrUtil.blankToDefault(u.getUsername(), a.getUsername()); + String realName = StrUtil.blankToDefault(u.getName(), + StrUtil.blankToDefault(u.getNickname(), a.getRealName())); + String mobile = StrUtil.blankToDefault(u.getPhone(), a.getMobile()); + boolean changed = !Objects.equals(username, a.getUsername()) || !Objects.equals(realName, a.getRealName()) + || !Objects.equals(mobile, a.getMobile()); + a.setUsername(username); + a.setRealName(realName); + a.setMobile(mobile); + if (persist && changed && a.getId() != null) { + dirty.add(a); + } + } + if (persist && !dirty.isEmpty()) { + updateBatchById(dirty); + } + } + + private Map loadUserMap(List userIds) { + try { + R> resp = remoteUserService.getInnerUserById(userIds); + List users = resp != null && resp.isOk() ? resp.getData() : null; + if (CollUtil.isEmpty(users)) { + return Map.of(); + } + return users.stream().filter(u -> u != null && u.getUserId() != null) + .collect(Collectors.toMap(SysUser::getUserId, u -> u, (a, b) -> a)); + } + catch (Exception e) { + log.warn("|酒店分销|同步用户资料失败|err={}|", e.getMessage()); + return Map.of(); + } + } + + private void applyUserProfile(SmHotelFxAgentEntity agent) { + if (agent == null || agent.getUserId() == null) { + return; + } + Map map = loadUserMap(List.of(agent.getUserId())); + SysUser u = map.get(agent.getUserId()); + if (u == null) { + return; + } + agent.setUsername(u.getUsername()); + agent.setRealName(StrUtil.blankToDefault(u.getName(), u.getNickname())); + agent.setMobile(u.getPhone()); + } + @Override @Transactional(rollbackFor = Exception.class) public R saveAgent(SmHotelFxAgentEntity body) { @@ -96,21 +175,28 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpllambdaQuery() .eq(SmHotelFxAgentEntity::getUserId, body.getUserId())); if (exists != null && exists > 0) { return R.failed("该用户已是分销员"); } - if (StrUtil.isBlank(body.getInviteCode())) { - body.setInviteCode("FX" + IdUtil.getSnowflakeNextIdStr().substring(10)); - } + // 邀请码固定 FX 前缀,注册页按前缀与部门邀请码分流 + body.setInviteCode(normalizeFxInviteCode(body.getInviteCode())); if (body.getPromoteScore() == null) { body.setPromoteScore(BigDecimal.ZERO); } if (body.getDemoteWindowScore() == null) { body.setDemoteWindowScore(BigDecimal.ZERO); } + body.setPayMethod(StrUtil.trim(body.getPayMethod())); + body.setPayAccount(StrUtil.trim(body.getPayAccount())); + body.setPayAccountName(StrUtil.trim(body.getPayAccountName())); if (body.getParentId() != null && (body.getLevelNo() == null || body.getLevelNo() == 4)) { SmHotelFxAgentEntity parent = getById(body.getParentId()); if (parent != null && Integer.valueOf(4).equals(parent.getLevelNo())) { @@ -128,16 +214,29 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpllambdaQuery() + .eq(SmHotelFxAgentEntity::getUserId, body.getUserId()) + .ne(SmHotelFxAgentEntity::getId, old.getId())); + if (clash != null && clash > 0) { + return R.failed("该用户已是分销员"); + } + old.setUserId(body.getUserId()); + } Integer prevLevel = old.getLevelNo(); + old.setUsername(body.getUsername()); old.setRealName(body.getRealName()); old.setMobile(body.getMobile()); - old.setUsername(body.getUsername()); old.setLevelNo(body.getLevelNo()); old.setParentId(body.getParentId()); old.setCityAdcode(body.getCityAdcode()); old.setRegionJson(body.getRegionJson()); old.setStatus(body.getStatus()); old.setRemark(body.getRemark()); + old.setPayMethod(StrUtil.trim(body.getPayMethod())); + old.setPayAccount(StrUtil.trim(body.getPayAccount())); + old.setPayAccountName(StrUtil.trim(body.getPayAccountName())); // 人工调到二级:开启保级窗口;从二级调走:清窗口 if (Integer.valueOf(2).equals(old.getLevelNo()) && !Integer.valueOf(2).equals(prevLevel)) { initLevel2DemoteWindow(old); @@ -209,19 +308,24 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl listBoundByHotelIds(Collection hotelIds) { + if (hotelIds == null || hotelIds.isEmpty()) { + return Collections.emptyList(); + } + List ids = hotelIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (ids.isEmpty()) { + return Collections.emptyList(); + } + List list = bindMapper.selectList(Wrappers.lambdaQuery() + .in(SmHotelFxHotelBindEntity::getHotelId, ids) + .eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND")); + fillBindDisplay(list); + return list; + } + + @Override + public Page pageMineHotels(Page page) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return page; + } + SmHotelFxAgentEntity me = getByUserId(user.getId()); + if (me == null) { + return page; + } + return pageBinds(page, me.getId(), null); + } + + private void fillBindDisplay(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + for (SmHotelFxHotelBindEntity b : list) { SmHotelEntity h = hotelMapper.selectById(b.getHotelId()); if (h != null) { b.setHotelName(h.getHotelName()); @@ -258,7 +399,6 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl previewInvite(String inviteCode) { + SmHotelFxAgentEntity parent = findActiveByInviteCode(inviteCode); + if (parent == null) { + return R.failed("分销邀请码错误或已失效"); + } + Integer lv = parent.getLevelNo(); + if (lv == null || lv < 1 || lv > 4) { + return R.failed("该邀请人层级无法邀请下级(仅 1~4 级可邀请)"); + } + int childLevel = lv <= 3 ? 4 : 5; + Long deptId = null; + try { + Map map = loadUserMap(List.of(parent.getUserId())); + SysUser u = map.get(parent.getUserId()); + if (u != null) { + deptId = u.getDeptId(); + } + } + catch (Exception e) { + log.warn("|酒店分销|邀请预览取部门失败|agentId={}|{}", parent.getId(), e.getMessage()); + } + com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO vo = new com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO(); + vo.setInviteCode(parent.getInviteCode()); + vo.setParentAgentId(parent.getId()); + vo.setParentUserId(parent.getUserId()); + vo.setDeptId(deptId); + vo.setParentName(StrUtil.blankToDefault(parent.getRealName(), parent.getUsername())); + vo.setParentLevel(lv); + vo.setChildLevel(childLevel); + vo.setHint(StrUtil.format("加入 {} 的分销团队,将成为{}级分销员", vo.getParentName(), childLevel)); + return R.ok(vo); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R createAgentByInvite(String inviteCode, Long userId, String username, String realName, + String mobile) { + if (userId == null) { + return R.failed("用户为空"); + } + SmHotelFxAgentEntity exist = getOne(Wrappers.lambdaQuery() + .eq(SmHotelFxAgentEntity::getUserId, userId) + .last("limit 1"), false); + if (exist != null) { + return R.failed("该用户已是分销员"); + } + R preview = previewInvite(inviteCode); + if (preview == null || preview.getCode() != 0 || preview.getData() == null) { + return R.failed(preview == null ? "邀请码无效" : StrUtil.blankToDefault(preview.getMsg(), "邀请码无效")); + } + com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO p = preview.getData(); + SmHotelFxAgentEntity body = new SmHotelFxAgentEntity(); + body.setUserId(userId); + body.setUsername(username); + body.setRealName(StrUtil.blankToDefault(realName, username)); + body.setMobile(mobile); + body.setLevelNo(p.getChildLevel()); + body.setParentId(p.getParentAgentId()); + body.setStatus("ACTIVE"); + body.setPromoteScore(BigDecimal.ZERO); + body.setDemoteWindowScore(BigDecimal.ZERO); + body.setInviteCode(normalizeFxInviteCode(null)); + save(body); + log.info("|酒店分销|邀请注册挂靠|userId={}|parent={}|level={}|", userId, p.getParentAgentId(), + p.getChildLevel()); + return R.ok(Boolean.TRUE); + } + + /** 空则生成;非空则补齐 FX 前缀,保证与部门邀请码格式互斥 */ + private String normalizeFxInviteCode(String inviteCode) { + if (StrUtil.isBlank(inviteCode)) { + return "FX" + IdUtil.getSnowflakeNextIdStr().substring(10); + } + String code = inviteCode.trim(); + return StrUtil.startWithIgnoreCase(code, "FX") ? code : "FX" + code; + } + + private SmHotelFxAgentEntity findActiveByInviteCode(String inviteCode) { + if (StrUtil.isBlank(inviteCode)) { + return null; + } + return getOne(Wrappers.lambdaQuery() + .eq(SmHotelFxAgentEntity::getInviteCode, inviteCode.trim()) + .eq(SmHotelFxAgentEntity::getStatus, "ACTIVE") + .last("limit 1"), false); + } + @Override @Transactional(rollbackFor = Exception.class) public void onPromoteBaseLocked(Long ownerAgentId, BigDecimal promoteBaseAmount) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxCommissionServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxCommissionServiceImpl.java index c580f8a94..2dbc012d6 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxCommissionServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxCommissionServiceImpl.java @@ -95,18 +95,18 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl r, (a, b) -> a)); // 开发员向上链路:每人只拿「自身当前层级」对应份额(缺层则该份额不发) + // 特例:五级开发酒店 → 开发佣金归五级(按四级份额);其四级上级本单不拿佣金(仅下线绩效) List chain = buildUplineChain(owner); Map snapshot = buildSnapshot(cfg, rules, cost, pool, promoteBase, owner.getId()); List rows = new ArrayList<>(); for (SmHotelFxAgentEntity agent : chain) { int lv = agent.getLevelNo() == null ? 0 : agent.getLevelNo(); - SmHotelFxLevelRuleDTO rule = ruleByLevel.get(lv); - if (rule == null || rule.getShareOfPool() == null - || rule.getShareOfPool().compareTo(BigDecimal.ZERO) <= 0) { + BigDecimal share = resolveShareOfPool(owner, agent, ruleByLevel); + if (share == null || share.compareTo(BigDecimal.ZERO) <= 0) { continue; } - BigDecimal amount = pool.multiply(rule.getShareOfPool()).setScale(4, RoundingMode.HALF_UP); + BigDecimal amount = pool.multiply(share).setScale(4, RoundingMode.HALF_UP); if (amount.compareTo(BigDecimal.ZERO) <= 0) { continue; } @@ -119,13 +119,15 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl + *
  • 开发员为五级:本人按四级 shareOfPool 计开发佣金
  • + *
  • 开发员为五级:链上四级上级本单份额跳过(佣金归五级,四级只看下线绩效)
  • + *
  • 其余:按自身 level 的 shareOfPool
  • + * + */ + private BigDecimal resolveShareOfPool(SmHotelFxAgentEntity owner, SmHotelFxAgentEntity agent, + Map ruleByLevel) { + if (owner == null || agent == null) { + return null; + } + int lv = agent.getLevelNo() == null ? 0 : agent.getLevelNo(); + boolean ownerIsL5 = Integer.valueOf(5).equals(owner.getLevelNo()); + boolean isOwner = Objects.equals(agent.getId(), owner.getId()); + if (ownerIsL5 && !isOwner && lv == 4) { + return null; + } + if (isOwner && lv == 5) { + SmHotelFxLevelRuleDTO l4 = ruleByLevel.get(4); + if (l4 != null && l4.getShareOfPool() != null + && l4.getShareOfPool().compareTo(BigDecimal.ZERO) > 0) { + return l4.getShareOfPool(); + } + return new BigDecimal("0.10"); + } + SmHotelFxLevelRuleDTO rule = ruleByLevel.get(lv); + return rule == null ? null : rule.getShareOfPool(); + } + /** 从开发员沿 parent 向上,同层级只保留最先遇到的人 */ private List buildUplineChain(SmHotelFxAgentEntity owner) { List chain = new ArrayList<>(); @@ -276,6 +309,12 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl(1, 1), me.getId(), null).getTotal()); List rules = configService.parseLevelRules(configService.getOrCreateCurrent()); + SmHotelFxLevelRuleDTO r1 = rules.stream().filter(r -> Objects.equals(r.getLevel(), 1)).findFirst().orElse(null); + BigDecimal l1Need = r1 != null && r1.getPromoteMin() != null ? r1.getPromoteMin() : new BigDecimal("500000"); + data.put("l1PromoteMin", l1Need); + // 下一档晋升门槛:挂在目标层级(4→3 看三级 5 万;3→2 看二级 20 万;2→1 看一级 50 万) + Integer lv = me.getLevelNo(); + if (lv != null && lv >= 2 && lv <= 4) { + int target = lv - 1; + SmHotelFxLevelRuleDTO targetRule = rules.stream().filter(r -> Objects.equals(r.getLevel(), target)) + .findFirst().orElse(null); + BigDecimal nextNeed = targetRule != null && targetRule.getPromoteMin() != null ? targetRule.getPromoteMin() + : (target == 3 ? new BigDecimal("50000") + : target == 2 ? new BigDecimal("200000") : l1Need); + data.put("nextPromoteLevel", target); + data.put("nextPromoteName", targetRule != null ? targetRule.getName() : null); + data.put("nextPromoteMin", nextNeed); + } + else { + data.put("nextPromoteLevel", null); + data.put("nextPromoteName", null); + data.put("nextPromoteMin", null); + } if (Integer.valueOf(2).equals(me.getLevelNo())) { SmHotelFxLevelRuleDTO r2 = rules.stream().filter(r -> Objects.equals(r.getLevel(), 2)).findFirst() .orElse(null); data.put("demoteMin", r2 != null ? r2.getDemoteMin() : new BigDecimal("40000")); data.put("demoteWindowDays", r2 != null ? r2.getDemoteWindowDays() : 90); - SmHotelFxLevelRuleDTO r1 = rules.stream().filter(r -> Objects.equals(r.getLevel(), 1)).findFirst() - .orElse(null); - BigDecimal l1Need = r1 != null && r1.getPromoteMin() != null ? r1.getPromoteMin() : new BigDecimal("500000"); - data.put("l1PromoteMin", l1Need); BigDecimal score = me.getPromoteScore() == null ? BigDecimal.ZERO : me.getPromoteScore(); data.put("canApplyL1", score.compareTo(l1Need) >= 0); } @@ -305,6 +363,70 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl> downlinePerf() { + SmHotelFxAgentEntity me = currentAgent(); + if (me == null) { + return R.failed("当前账号不是分销员"); + } + List children = agentService.list(Wrappers.lambdaQuery() + .eq(SmHotelFxAgentEntity::getParentId, me.getId()) + .eq(SmHotelFxAgentEntity::getStatus, "ACTIVE") + .orderByAsc(SmHotelFxAgentEntity::getLevelNo) + .orderByDesc(SmHotelFxAgentEntity::getId)); + BigDecimal totalEstimated = BigDecimal.ZERO; + BigDecimal totalLocked = BigDecimal.ZERO; + BigDecimal totalPromote = BigDecimal.ZERO; + long totalHotels = 0L; + List> items = new ArrayList<>(); + for (SmHotelFxAgentEntity child : children) { + List commissions = list(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getAgentId, child.getId()) + .ne(SmHotelFxCommissionEntity::getStatus, ST_CANCEL)); + BigDecimal estimated = BigDecimal.ZERO; + BigDecimal locked = BigDecimal.ZERO; + for (SmHotelFxCommissionEntity c : commissions) { + BigDecimal amt = c.getCommissionAmount() == null ? BigDecimal.ZERO : c.getCommissionAmount(); + if (ST_EST.equals(c.getStatus())) { + estimated = estimated.add(amt); + } + else if (ST_LOCK.equals(c.getStatus()) || "WITHDRAWN".equals(c.getStatus())) { + locked = locked.add(amt); + } + } + long hotelCount = agentService.pageBinds(new Page<>(1, 1), child.getId(), null).getTotal(); + BigDecimal promote = child.getPromoteScore() == null ? BigDecimal.ZERO : child.getPromoteScore(); + Map row = new LinkedHashMap<>(); + row.put("agentId", child.getId()); + row.put("realName", child.getRealName()); + row.put("username", child.getUsername()); + row.put("mobile", child.getMobile()); + row.put("levelNo", child.getLevelNo()); + row.put("inviteCode", child.getInviteCode()); + row.put("hotelCount", hotelCount); + row.put("promoteScore", promote); + row.put("estimatedAmount", estimated); + row.put("lockedAmount", locked); + row.put("totalAmount", estimated.add(locked)); + items.add(row); + totalEstimated = totalEstimated.add(estimated); + totalLocked = totalLocked.add(locked); + totalPromote = totalPromote.add(promote); + totalHotels += hotelCount; + } + items.sort((a, b) -> ((BigDecimal) b.get("totalAmount")).compareTo((BigDecimal) a.get("totalAmount"))); + Map data = new LinkedHashMap<>(); + data.put("childCount", items.size()); + data.put("hotelCount", totalHotels); + data.put("promoteScore", totalPromote); + data.put("estimatedAmount", totalEstimated); + data.put("lockedAmount", totalLocked); + data.put("totalAmount", totalEstimated.add(totalLocked)); + data.put("hint", "下线酒店订单佣金归下线本人;此处仅汇总查看绩效,不计入您的可提现"); + data.put("items", items); + return R.ok(data); + } + @Override public Page pageMine(Page page, String status) { SmHotelFxAgentEntity me = currentAgent(); @@ -325,7 +447,13 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl list) { @@ -354,6 +482,109 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl> mockOrderForBoundHotel(Long hotelId, BigDecimal costAmount, Boolean lockNow) { + if (hotelId == null) { + return R.failed("酒店ID为空"); + } + SmHotelFxAgentEntity owner = agentService.getBoundAgentByHotelId(hotelId); + if (owner == null || !"ACTIVE".equals(owner.getStatus())) { + return R.failed("该酒店未绑定在职分销员,请先完成分销绑定"); + } + SmHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null) { + return R.failed("酒店不存在"); + } + SmHotelFxConfigEntity cfg = configService.getOrCreateCurrent(); + if (!configService.isEnabled(cfg)) { + return R.failed("分销未开启,请先在后台启用酒店分销配置"); + } + BigDecimal cost = costAmount; + if (cost == null || cost.compareTo(BigDecimal.ZERO) <= 0) { + cost = new BigDecimal("1000"); + } + cost = cost.setScale(2, RoundingMode.HALF_UP); + boolean doLock = Boolean.TRUE.equals(lockNow); + LocalDate checkIn = LocalDate.now().minusDays(doLock ? 2 : 0); + LocalDate checkOut = doLock ? LocalDate.now().minusDays(1) : LocalDate.now().plusDays(1); + + SmHotelOrderEntity order = new SmHotelOrderEntity(); + order.setHotelId(hotelId); + order.setHotelCode(hotel.getHotelCode()); + order.setGroupCode(hotel.getGroupCode()); + order.setPartnerCode("FX_MOCK"); + order.setChannelNo("FXMOCK" + owner.getId() + System.currentTimeMillis()); + order.setRoomTypeCode("FX-MOCK-ROOM"); + order.setProductCode("FX-MOCK-PROD"); + order.setCheckInDate(checkIn); + order.setCheckOutDate(checkOut); + order.setCount(1); + order.setCostAmount(cost); + order.setSellAmount(cost.multiply(new BigDecimal("1.2")).setScale(2, RoundingMode.HALF_UP)); + order.setContactName("分销模拟"); + order.setContactPhone(StrUtil.blankToDefault(owner.getMobile(), "10000000000")); + order.setOrderStatus("UPSTREAM_OK"); + order.setBookStatus("R"); + order.setPayStatus("1"); + order.setInventoryStatus("NONE"); + order.setCompensateStatus("NONE"); + order.setFailMsg("分销联调模拟单"); + orderMapper.insert(order); + createEstimatedForOrder(order); + + List lines = list(Wrappers.lambdaQuery() + .eq(SmHotelFxCommissionEntity::getOrderId, order.getId()) + .ne(SmHotelFxCommissionEntity::getStatus, ST_CANCEL)); + if (CollUtil.isEmpty(lines)) { + return R.failed("订单已建但未生成佣金,请检查层级份额配置"); + } + if (doLock) { + LocalDateTime now = LocalDateTime.now(); + Map promoteAdd = new HashMap<>(); + List ownerRows = new ArrayList<>(); + for (SmHotelFxCommissionEntity row : lines) { + if (!ST_EST.equals(row.getStatus())) { + continue; + } + row.setStatus(ST_LOCK); + row.setLockTime(now); + row.setRemark("模拟单立即锁定"); + if (Objects.equals(row.getAgentId(), row.getOwnerAgentId()) && row.getPromoteBaseAmount() != null) { + promoteAdd.merge(row.getOwnerAgentId(), row.getPromoteBaseAmount(), BigDecimal::add); + ownerRows.add(row); + } + } + updateBatchById(lines); + for (SmHotelFxCommissionEntity row : ownerRows) { + agentService.onDemoteWindowEligible(row.getOwnerAgentId(), row.getHotelId(), row.getPromoteBaseAmount()); + } + for (Map.Entry e : promoteAdd.entrySet()) { + agentService.onPromoteBaseLocked(e.getKey(), e.getValue()); + } + } + + BigDecimal ownerAmt = lines.stream() + .filter(c -> Objects.equals(c.getAgentId(), owner.getId())) + .map(c -> c.getCommissionAmount() == null ? BigDecimal.ZERO : c.getCommissionAmount()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + Map data = new LinkedHashMap<>(); + data.put("orderId", order.getId()); + data.put("channelNo", order.getChannelNo()); + data.put("hotelId", hotelId); + data.put("hotelName", hotel.getHotelName()); + data.put("ownerAgentId", owner.getId()); + data.put("ownerAgentName", StrUtil.blankToDefault(owner.getRealName(), owner.getUsername())); + data.put("costAmount", cost); + data.put("checkOutDate", checkOut.toString()); + data.put("locked", doLock); + data.put("commissionLines", lines.size()); + data.put("ownerCommission", ownerAmt); + log.info("|酒店分销|管理端模拟下单|orderId={}|hotelId={}|agentId={}|cost={}|lock={}|lines={}|", + order.getId(), hotelId, owner.getId(), cost, doLock, lines.size()); + return R.ok(data); + } + private static BigDecimal nvl(BigDecimal v, String def) { return v == null ? new BigDecimal(def) : v; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxHotelApplyServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxHotelApplyServiceImpl.java new file mode 100644 index 000000000..6f1b486ac --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxHotelApplyServiceImpl.java @@ -0,0 +1,302 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +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.common.security.service.PjcloudUser; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxAgentEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelApplyEntity; +import com.bsd.pjcloud.supermarket.mapper.SmHotelFxHotelApplyMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelFxAgentService; +import com.bsd.pjcloud.supermarket.service.SmHotelFxHotelApplyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 分销员仅提报:酒店名称 + 地址 + 营业执照。 + * 后台完善酒店资料后,审核通过时选择已建酒店绑定,并在审核备注中填写酒店账号密码(分销员可见)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SmHotelFxHotelApplyServiceImpl extends ServiceImpl + implements SmHotelFxHotelApplyService { + + private final SmHotelFxAgentService agentService; + + private final SmHotelMapper hotelMapper; + + @Override + public Page pageAdmin(Page page, + SmHotelFxHotelApplyEntity query) { + Page result = page(page, Wrappers.lambdaQuery() + .eq(query.getAgentId() != null, SmHotelFxHotelApplyEntity::getAgentId, query.getAgentId()) + .eq(StrUtil.isNotBlank(query.getStatus()), SmHotelFxHotelApplyEntity::getStatus, query.getStatus()) + .like(StrUtil.isNotBlank(query.getHotelName()), SmHotelFxHotelApplyEntity::getHotelName, + query.getHotelName()) + .orderByDesc(SmHotelFxHotelApplyEntity::getId)); + fillAgent(result.getRecords()); + fillLicense(result.getRecords()); + return result; + } + + @Override + public Page pageMine(Page page, String status) { + SmHotelFxAgentEntity me = requireActiveAgent(); + if (me == null) { + return page; + } + Page result = page(page, Wrappers.lambdaQuery() + .eq(SmHotelFxHotelApplyEntity::getAgentId, me.getId()) + .eq(StrUtil.isNotBlank(status), SmHotelFxHotelApplyEntity::getStatus, status) + .orderByDesc(SmHotelFxHotelApplyEntity::getId)); + fillLicense(result.getRecords()); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R applyMine(SmHotelFxHotelApplyEntity body) { + SmHotelFxAgentEntity me = requireActiveAgent(); + if (me == null) { + return R.failed("当前账号不是在职分销员"); + } + if (body == null) { + return R.failed("请填写酒店信息"); + } + JSONObject payload = parsePayload(body); + String hotelName = firstNonBlank(payload.getStr("hotelName"), body.getHotelName()); + String address = firstNonBlank(payload.getStr("address"), body.getAddress()); + String licenseUrl = firstNonBlank(payload.getStr("licenseUrl"), payload.getStr("businessLicense"), + body.getLicenseUrl()); + if (StrUtil.isBlank(hotelName)) { + return R.failed("请填写酒店名称"); + } + if (StrUtil.isBlank(address)) { + return R.failed("请填写地址"); + } + if (StrUtil.isBlank(licenseUrl)) { + return R.failed("请上传营业执照"); + } + hotelName = hotelName.trim(); + address = address.trim(); + licenseUrl = licenseUrl.trim(); + + // PENDING:同单修改;REJECTED:必须新建记录保留驳回痕迹;其它状态不可改 + SmHotelFxHotelApplyEntity pendingEdit = null; + Long resubmitFromId = null; + if (body.getId() != null) { + SmHotelFxHotelApplyEntity exist = getById(body.getId()); + if (exist == null || !java.util.Objects.equals(exist.getAgentId(), me.getId())) { + return R.failed("提报不存在或无权编辑"); + } + if ("PENDING".equals(exist.getStatus())) { + pendingEdit = exist; + } + else if ("REJECTED".equals(exist.getStatus())) { + resubmitFromId = exist.getId(); + } + else { + return R.failed("仅待审或已驳回的提报可重新编辑提交"); + } + } + + long pending = count(Wrappers.lambdaQuery() + .eq(SmHotelFxHotelApplyEntity::getAgentId, me.getId()) + .eq(SmHotelFxHotelApplyEntity::getStatus, "PENDING") + .eq(SmHotelFxHotelApplyEntity::getHotelName, hotelName) + .ne(pendingEdit != null, SmHotelFxHotelApplyEntity::getId, + pendingEdit != null ? pendingEdit.getId() : null)); + if (pending > 0) { + return R.failed("同名酒店已有待审核提报,请勿重复提交"); + } + + JSONObject slim = new JSONObject(); + slim.set("hotelName", hotelName); + slim.set("address", address); + slim.set("licenseUrl", licenseUrl); + if (resubmitFromId != null) { + slim.set("resubmitFromId", resubmitFromId); + } + + SmHotelFxHotelApplyEntity row = pendingEdit != null ? pendingEdit : new SmHotelFxHotelApplyEntity(); + row.setAgentId(me.getId()); + row.setUserId(me.getUserId()); + row.setHotelName(hotelName); + row.setAddress(address); + row.setHotelJson(slim.toString()); + row.setLicenseUrl(licenseUrl); + row.setStatus("PENDING"); + row.setRemark(StrUtil.trim(body.getRemark())); + if (pendingEdit != null) { + updateById(row); + log.info("|酒店分销|提报修改|applyId={}|agentId={}|hotel={}|", row.getId(), me.getId(), hotelName); + } + else { + save(row); + log.info("|酒店分销|提报提交|applyId={}|agentId={}|hotel={}|fromRejected={}|", + row.getId(), me.getId(), hotelName, resubmitFromId); + } + return R.ok(row); + } + + @Override + public R getMine(Long id) { + SmHotelFxAgentEntity me = requireActiveAgent(); + if (me == null) { + return R.failed("当前账号不是在职分销员"); + } + if (id == null) { + return R.failed("提报ID为空"); + } + SmHotelFxHotelApplyEntity row = getById(id); + if (row == null || !java.util.Objects.equals(row.getAgentId(), me.getId())) { + return R.failed("提报不存在"); + } + fillLicense(java.util.Collections.singletonList(row)); + return R.ok(row); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R audit(Long id, boolean approved, String auditRemark, Long hotelId) { + SmHotelFxHotelApplyEntity row = getById(id); + if (row == null) { + return R.failed("提报不存在"); + } + if (!"PENDING".equals(row.getStatus())) { + return R.failed("提报已处理"); + } + PjcloudUser user = SecurityUtils.getUser(); + row.setAuditBy(user != null ? user.getUsername() : null); + row.setAuditTime(LocalDateTime.now()); + row.setAuditRemark(StrUtil.trim(auditRemark)); + if (!approved) { + row.setStatus("REJECTED"); + updateById(row); + return R.ok(Boolean.TRUE, "已驳回"); + } + if (StrUtil.isBlank(row.getAuditRemark())) { + return R.failed("请填写审核备注(酒店账号密码等,分销员可见)"); + } + if (hotelId == null) { + return R.failed("请选择要绑定的酒店(请先在酒店管理完善资料)"); + } + SmHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null) { + return R.failed("酒店不存在,请先在酒店管理新建并完善"); + } + SmHotelFxAgentEntity agent = agentService.getById(row.getAgentId()); + if (agent == null || !"ACTIVE".equals(agent.getStatus())) { + return R.failed("提报分销员不可用"); + } + R bind = agentService.bindHotel(hotelId, agent.getId(), "分销提报审核通过"); + if (bind == null || bind.getCode() != 0) { + return R.failed(bind == null ? "绑定失败" : StrUtil.blankToDefault(bind.getMsg(), "绑定失败")); + } + row.setHotelId(hotelId); + row.setStatus("APPROVED"); + updateById(row); + log.info("|酒店分销|提报通过|applyId={}|hotelId={}|agentId={}|", row.getId(), hotelId, agent.getId()); + return R.ok(Boolean.TRUE, "已通过并绑定分销员"); + } + + @Override + public long countPendingMine() { + SmHotelFxAgentEntity me = requireActiveAgent(); + if (me == null) { + return 0L; + } + return count(Wrappers.lambdaQuery() + .eq(SmHotelFxHotelApplyEntity::getAgentId, me.getId()) + .eq(SmHotelFxHotelApplyEntity::getStatus, "PENDING")); + } + + private JSONObject parsePayload(SmHotelFxHotelApplyEntity body) { + if (StrUtil.isNotBlank(body.getHotelJson()) && JSONUtil.isTypeJSON(body.getHotelJson())) { + return JSONUtil.parseObj(body.getHotelJson()); + } + JSONObject p = new JSONObject(); + if (StrUtil.isNotBlank(body.getHotelName())) { + p.set("hotelName", body.getHotelName().trim()); + } + if (StrUtil.isNotBlank(body.getAddress())) { + p.set("address", body.getAddress().trim()); + } + if (StrUtil.isNotBlank(body.getLicenseUrl())) { + p.set("licenseUrl", body.getLicenseUrl().trim()); + } + return p; + } + + private String firstNonBlank(String... vals) { + if (vals == null) { + return null; + } + for (String v : vals) { + if (StrUtil.isNotBlank(v)) { + return v.trim(); + } + } + return null; + } + + private SmHotelFxAgentEntity requireActiveAgent() { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return null; + } + SmHotelFxAgentEntity me = agentService.getByUserId(user.getId()); + if (me == null || !"ACTIVE".equals(me.getStatus())) { + return null; + } + return me; + } + + private void fillAgent(List list) { + if (list == null || list.isEmpty()) { + return; + } + for (SmHotelFxHotelApplyEntity row : list) { + if (row.getAgentId() == null) { + continue; + } + SmHotelFxAgentEntity a = agentService.getById(row.getAgentId()); + if (a != null) { + row.setAgentName(StrUtil.blankToDefault(a.getRealName(), a.getUsername())); + } + } + } + + /** 从 hotel_json 回填营业执照 URL,方便前后端展示 */ + private void fillLicense(List list) { + if (list == null || list.isEmpty()) { + return; + } + for (SmHotelFxHotelApplyEntity row : list) { + if (StrUtil.isNotBlank(row.getLicenseUrl())) { + continue; + } + if (StrUtil.isBlank(row.getHotelJson()) || !JSONUtil.isTypeJSON(row.getHotelJson())) { + continue; + } + JSONObject p = JSONUtil.parseObj(row.getHotelJson()); + String url = firstNonBlank(p.getStr("licenseUrl"), p.getStr("businessLicense")); + if (StrUtil.isNotBlank(url)) { + row.setLicenseUrl(url); + } + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxWithdrawServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxWithdrawServiceImpl.java index 84cd193ff..df9ca02a5 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxWithdrawServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelFxWithdrawServiceImpl.java @@ -199,7 +199,11 @@ public class SmHotelFxWithdrawServiceImpl extends ServiceImpl list) { 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 ee90c6b4b..02ae1950e 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 @@ -1919,19 +1919,27 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } SmHotelDetailVO vo = new SmHotelDetailVO(); vo.setHotel(hotel); + // 详情列表不带 rawJson:上游原文很大,管理端 UI 不用,会显著拖慢详情接口 vo.setRooms(roomMapper.selectList(Wrappers.lambdaQuery() + .select(SmHotelRoomEntity.class, i -> !"rawJson".equals(i.getProperty())) .eq(SmHotelRoomEntity::getHotelId, hotelId) .orderByAsc(SmHotelRoomEntity::getRoomTypeCode))); vo.setProducts(productMapper.selectList(Wrappers.lambdaQuery() + .select(SmHotelProductEntity.class, i -> !"rawJson".equals(i.getProperty())) .eq(SmHotelProductEntity::getHotelId, hotelId) .orderByAsc(SmHotelProductEntity::getProductCode))); + // 详情预览只统计近 90 天,避免「今天起无上界 COUNT」扫爆日历大表拖慢详情 + LocalDate dailyFrom = LocalDate.now(); + LocalDate dailyToEx = dailyFrom.plusDays(90); Long dailyCount = dailyMapper.selectCount(Wrappers.lambdaQuery() .eq(SmHotelDailyEntity::getHotelId, hotelId) - .ge(SmHotelDailyEntity::getStayDate, LocalDate.now())); + .ge(SmHotelDailyEntity::getStayDate, dailyFrom) + .lt(SmHotelDailyEntity::getStayDate, dailyToEx)); vo.setDailyCount(dailyCount); vo.setDailyPreview(dailyMapper.selectList(Wrappers.lambdaQuery() .eq(SmHotelDailyEntity::getHotelId, hotelId) - .ge(SmHotelDailyEntity::getStayDate, LocalDate.now()) + .ge(SmHotelDailyEntity::getStayDate, dailyFrom) + .lt(SmHotelDailyEntity::getStayDate, dailyToEx) .orderByAsc(SmHotelDailyEntity::getStayDate) .orderByAsc(SmHotelDailyEntity::getProductCode) .last("limit 30"))); @@ -2874,12 +2882,12 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } /** - * 日历落库:先按半开区间物理删除再批量插入。 + * 日历落库:窗内 upsert(有则改、无则批量插);上游未带回的日期关房+库存 0,不做整窗物理删。 *

    - * 唯一键 uk_product_date=(tenant_id, product_id, stay_date) 不含 del_flag,必须物理删。 - * 删除区间须覆盖本次将写入的日期(含回调报文里的历史日),不能写死 LocalDate.now()。 + * 唯一键 uk_product_date=(tenant_id, product_id, stay_date)。更新时保留 sold。 + * 过期(今天往前 30 天)顺带物理清理,避免关房行无限堆积。 *

    - * 全量详情多酒店并行时,同租户唯一索引上易出现死锁,失败自动重试几次。 + * 全量/近窗多酒店并行时仍可能死锁,失败自动重试几次。 */ private int persistDailies(SmHotelEntity hotel, List drafts, LocalDate fromInclusive, LocalDate toExclusive) { @@ -2912,10 +2920,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { private int persistDailiesOnce(SmHotelEntity hotel, List drafts, LocalDate fromInclusive, LocalDate toExclusive) { - LocalDate delFrom = fromInclusive; - LocalDate delTo = toExclusive; - if (delFrom == null || delTo == null || !delFrom.isBefore(delTo)) { - // 兜底:无有效区间时按草稿最值;再无则只清今天起 + LocalDate winFrom = fromInclusive; + LocalDate winToEx = toExclusive; + if (winFrom == null || winToEx == null || !winFrom.isBefore(winToEx)) { if (CollUtil.isNotEmpty(drafts)) { LocalDate min = null; LocalDate max = null; @@ -2931,24 +2938,25 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } } if (min != null && max != null) { - delFrom = min; - delTo = max.plusDays(1); + winFrom = min; + winToEx = max.plusDays(1); } } - if (delFrom == null || delTo == null || !delFrom.isBefore(delTo)) { - delFrom = LocalDate.now(); - delTo = null; + if (winFrom == null || winToEx == null || !winFrom.isBefore(winToEx)) { + winFrom = LocalDate.now(); + winToEx = winFrom.plusDays(90); } } - if (delTo == null) { - dailyMapper.physicalDeleteFromDate(hotel.getId(), delFrom); + + // 过期日历物理清(单酒店、今天往前 30 天),与窗内 upsert 无关 + LocalDate expireBefore = LocalDate.now().minusDays(30); + try { + dailyMapper.physicalDeleteBefore(hotel.getId(), expireBefore); } - else { - dailyMapper.physicalDeleteRange(hotel.getId(), delFrom, delTo); - } - if (CollUtil.isEmpty(drafts)) { - return 0; + catch (Exception e) { + log.warn("|酒店日历落库|过期清理失败|hotelId={}|err={}|", hotel.getId(), e.getMessage()); } + Map productIdMap = new HashMap<>(); List products = productMapper .selectList(Wrappers.lambdaQuery() @@ -2957,34 +2965,91 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { for (SmHotelProductEntity p : products) { productIdMap.put(p.getProductCode(), p.getId()); } + Map unique = new LinkedHashMap<>(); - for (DailyDraft d : drafts) { - unique.put(d.productCode + "|" + d.stayDate, d); + if (CollUtil.isNotEmpty(drafts)) { + for (DailyDraft d : drafts) { + if (d == null || d.stayDate == null || StrUtil.isBlank(d.productCode)) { + continue; + } + if (d.stayDate.isBefore(winFrom) || !d.stayDate.isBefore(winToEx)) { + continue; + } + unique.put(d.productCode + "|" + d.stayDate, d); + } } + + List existRows = dailyMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelDailyEntity::getHotelId, hotel.getId()) + .ge(SmHotelDailyEntity::getStayDate, winFrom) + .lt(SmHotelDailyEntity::getStayDate, winToEx)); + Map existMap = new HashMap<>(Math.max(16, existRows.size() * 2)); + for (SmHotelDailyEntity row : existRows) { + if (row.getProductCode() == null || row.getStayDate() == null) { + continue; + } + existMap.put(row.getProductCode() + "|" + row.getStayDate(), row); + } + LocalDateTime now = LocalDateTime.now(); Long tenantId = resolveTenantId(); - List toInsert = new ArrayList<>(unique.size()); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + for (DailyDraft d : unique.values()) { Long productId = productIdMap.get(d.productCode); if (productId == null) { continue; } - SmHotelDailyEntity daily = new SmHotelDailyEntity(); - daily.setHotelId(hotel.getId()); - daily.setProductId(productId); - daily.setRoomTypeCode(d.roomTypeCode); - daily.setProductCode(d.productCode); - daily.setStayDate(d.stayDate); - daily.setCostPrice(d.costPrice); - daily.setSellPrice(d.sellPrice); - daily.setStock(d.stock); - daily.setSold(0); - daily.setDailyStatus(d.dailyStatus); - fillNewMeta(daily, tenantId, now); - toInsert.add(daily); + String key = d.productCode + "|" + d.stayDate; + SmHotelDailyEntity exist = existMap.remove(key); + if (exist == null) { + SmHotelDailyEntity daily = new SmHotelDailyEntity(); + daily.setHotelId(hotel.getId()); + daily.setProductId(productId); + daily.setRoomTypeCode(d.roomTypeCode); + daily.setProductCode(d.productCode); + daily.setStayDate(d.stayDate); + daily.setCostPrice(d.costPrice); + daily.setSellPrice(d.sellPrice); + daily.setStock(d.stock); + daily.setSold(0); + daily.setDailyStatus(d.dailyStatus); + fillNewMeta(daily, tenantId, now); + toInsert.add(daily); + continue; + } + // 有则改:保留 sold,避免冲掉本地占房 + exist.setProductId(productId); + exist.setRoomTypeCode(d.roomTypeCode); + exist.setCostPrice(d.costPrice); + exist.setSellPrice(d.sellPrice); + exist.setStock(d.stock); + exist.setDailyStatus(d.dailyStatus); + exist.setUpdateTime(now); + toUpdate.add(exist); } + + // 窗内本地有、上游本次未带回 → 关房 + 库存 0(不删行、不动 sold) + for (SmHotelDailyEntity left : existMap.values()) { + boolean alreadyClosed = Integer.valueOf(0).equals(left.getDailyStatus()) + && (left.getStock() == null || left.getStock() <= 0); + if (alreadyClosed) { + continue; + } + left.setStock(0); + left.setDailyStatus(0); + left.setUpdateTime(now); + toUpdate.add(left); + } + insertDailiesBatch(toInsert); - return toInsert.size(); + if (CollUtil.isNotEmpty(toUpdate)) { + Db.updateBatchById(toUpdate, PERSIST_BATCH); + } + log.debug("|酒店日历落库|upsert|hotelId={}|window={}~{}|in={}|up={}|closeCandidate={}|", hotel.getId(), + winFrom, winToEx, toInsert.size(), toUpdate.size(), existMap.size()); + return toInsert.size() + toUpdate.size(); } /** 并发拉详情时,日历表 uk_product_date 易出现 InnoDB 死锁 */ diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInvitePreviewVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInvitePreviewVO.java index cd4dbb5f4..9763a93b0 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInvitePreviewVO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInvitePreviewVO.java @@ -39,4 +39,16 @@ public class SysDeptInvitePreviewVO { @Schema(description = "过期时间") private java.time.LocalDateTime expireTime; + + @Schema(description = "邀请类型:DEPT=部门邀请 / FX=酒店分销邀请") + private String inviteKind; + + @Schema(description = "分销邀请提示文案") + private String hint; + + @Schema(description = "分销邀请人姓名") + private String parentName; + + @Schema(description = "注册后分销层级(仅 FX)") + private Integer childLevel; } diff --git a/pjcloud-upms/pjcloud-upms-biz/pom.xml b/pjcloud-upms/pjcloud-upms-biz/pom.xml index d21b51e61..dc9116edf 100644 --- a/pjcloud-upms/pjcloud-upms-biz/pom.xml +++ b/pjcloud-upms/pjcloud-upms-biz/pom.xml @@ -245,6 +245,13 @@ 5.7.0 compile + + + com.bsd + pjcloud-supermarket-api + 5.7.0 + compile + diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptInviteServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptInviteServiceImpl.java index f7ad8a9d2..15e126bb9 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptInviteServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptInviteServiceImpl.java @@ -2,7 +2,6 @@ package com.bsd.pjcloud.admin.service.impl; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.IdUtil; -import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.metadata.IPage; @@ -42,6 +41,8 @@ import com.bsd.pjcloud.common.core.util.MsgUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum; +import com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelFxInviteService; +import com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import lombok.RequiredArgsConstructor; @@ -80,6 +81,7 @@ public class SysDeptInviteServiceImpl extends ServiceImpllambdaQuery().eq(SysUser::getPhone, phone)); - if (phoneExists) { - return R.failed(MsgUtils.getMessage(ErrorCodes.SYS_USER_PHONE_EXISTING, phone)); + String code = dto.getToken().trim(); + if (isFxInviteCode(code)) { + return registerByFxInvite(dto, requireFxInvite(code), phone); + } + return registerByDeptInvite(dto, requireValidDeptInvite(code), phone); + } + + /** 酒店分销邀请码:FX + 数字/字母,与部门邀请码格式区分 */ + private boolean isFxInviteCode(String token) { + return StrUtil.isNotBlank(token) && StrUtil.startWithIgnoreCase(token.trim(), "FX"); + } + + private SysDeptInvitePreviewVO buildDeptPreview(SysDeptInviteEntity invite) { + SysDept dept = sysDeptService.getById(invite.getDeptId()); + MchManageVo mch = resolveMerchantAlongUnitChain(invite.getDeptId()); + String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode()) + : MerchantAttrEnum.NORMAL.getCode(); + MerchantAttrEnum attrEnum = MerchantAttrEnum.valueOfByCode(attr); + String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr); + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + + SysDeptInvitePreviewVO vo = new SysDeptInvitePreviewVO(); + vo.setInviteKind("DEPT"); + vo.setToken(invite.getToken()); + vo.setDeptId(invite.getDeptId()); + vo.setDeptName(dept != null ? dept.getName() : null); + vo.setNeedAudit("1".equals(invite.getNeedAudit())); + vo.setMerchantId(mch != null ? mch.getId() : null); + vo.setMerchantAttr(attr); + vo.setMerchantAttrLabel(attrEnum != null ? attrEnum.getLabel() : MerchantAttrEnum.NORMAL.getLabel()); + vo.setIdentityType(identityType); + vo.setIdentityTypeLabel(identity != null ? identity.getDescription() : UserIdentityTypeEnum.DEFAULT.getDescription()); + vo.setValidFrom(invite.getValidFrom()); + vo.setExpireTime(invite.getExpireTime()); + return vo; + } + + private SysDeptInvitePreviewVO buildFxPreview(SmHotelFxInvitePreviewVO fx) { + if (fx.getDeptId() == null) { + throw new IllegalArgumentException("邀请人账号未归属部门,无法通过邀请注册,请联系管理员"); + } + SysDept dept = sysDeptService.getById(fx.getDeptId()); + MchManageVo mch = resolveMerchantAlongUnitChain(fx.getDeptId()); + String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode()) + : MerchantAttrEnum.NORMAL.getCode(); + MerchantAttrEnum attrEnum = MerchantAttrEnum.valueOfByCode(attr); + String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr); + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + + SysDeptInvitePreviewVO vo = new SysDeptInvitePreviewVO(); + vo.setInviteKind("FX"); + vo.setToken(fx.getInviteCode()); + vo.setDeptId(fx.getDeptId()); + vo.setDeptName(dept != null ? dept.getName() : null); + vo.setNeedAudit(false); + vo.setMerchantId(mch != null ? mch.getId() : null); + vo.setMerchantAttr(attr); + vo.setMerchantAttrLabel(attrEnum != null ? attrEnum.getLabel() : MerchantAttrEnum.NORMAL.getLabel()); + vo.setIdentityType(identityType); + vo.setIdentityTypeLabel(identity != null ? identity.getDescription() : UserIdentityTypeEnum.DEFAULT.getDescription()); + vo.setHint(fx.getHint()); + vo.setParentName(fx.getParentName()); + vo.setChildLevel(fx.getChildLevel()); + return vo; + } + + private SmHotelFxInvitePreviewVO requireFxInvite(String token) { + if (StrUtil.isBlank(token)) { + throw new IllegalArgumentException("邀请码无效"); + } + R fxResp; + try { + fxResp = remoteSmHotelFxInviteService.preview(token.trim()); + } + catch (Exception ex) { + log.warn("|{}|分销邀请预览失败 token={} err={}", TAG, token, ex.getMessage()); + throw new IllegalArgumentException("邀请码错误或已失效"); + } + if (fxResp == null || !fxResp.isOk() || fxResp.getData() == null) { + throw new IllegalArgumentException(fxResp != null && StrUtil.isNotBlank(fxResp.getMsg()) + ? fxResp.getMsg() : "邀请码错误或已失效"); + } + return fxResp.getData(); + } + + private R registerByDeptInvite(DeptInviteRegisterDTO dto, SysDeptInviteEntity invite, String phone) { + PreparedInviteUser prepared = prepareInviteUser(dto, phone); + if (prepared.errorMsg != null) { + return R.failed(prepared.errorMsg); + } + + MchManageVo mch = resolveMerchantAlongUnitChain(invite.getDeptId()); + String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode()) + : MerchantAttrEnum.NORMAL.getCode(); + String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr); + boolean needAudit = "1".equals(invite.getNeedAudit()); + + SysUser sysUser = insertInviteUser(prepared, invite.getDeptId(), needAudit, phone); + assignDefaultRolesAndIdentity(sysUser, identityType); + + if (needAudit) { + startInviteUserFlow(sysUser, invite.getDeptId()); + return R.ok(true, "注册成功,请等待审核通过后再登录"); + } + + applicationEventPublisher.publishEvent( + new UserWalletCreateEvent(sysUser.getUserId(), TenantContextHolder.getTenantId())); + return R.ok(true, "注册成功"); + } + + private R registerByFxInvite(DeptInviteRegisterDTO dto, SmHotelFxInvitePreviewVO fx, String phone) { + if (fx.getDeptId() == null) { + return R.failed("邀请人账号未归属部门,无法通过邀请注册,请联系管理员"); + } + PreparedInviteUser prepared = prepareInviteUser(dto, phone); + if (prepared.errorMsg != null) { + return R.failed(prepared.errorMsg); + } + + MchManageVo mch = resolveMerchantAlongUnitChain(fx.getDeptId()); + String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode()) + : MerchantAttrEnum.NORMAL.getCode(); + String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr); + + SysUser sysUser = insertInviteUser(prepared, fx.getDeptId(), false, phone); + // 分销邀请:默认用户角色 + 钱包角色 + 分销员角色(App 商家工作台「分销管理」可见) + assignDefaultRolesAndIdentity(sysUser, identityType, "HOTEL_FX_AGENT"); + + R bind = remoteSmHotelFxInviteService.bindAgent(fx.getInviteCode(), sysUser.getUserId(), + sysUser.getUsername(), sysUser.getName(), phone); + if (bind == null || !bind.isOk()) { + throw new IllegalArgumentException( + bind != null && StrUtil.isNotBlank(bind.getMsg()) ? bind.getMsg() : "分销员挂靠失败,请稍后重试"); + } + + applicationEventPublisher.publishEvent( + new UserWalletCreateEvent(sysUser.getUserId(), TenantContextHolder.getTenantId())); + String tip = fx.getChildLevel() != null + ? StrUtil.format("注册成功,已成为{}级分销员", fx.getChildLevel()) + : "注册成功"; + return R.ok(true, tip); + } + + private static final class PreparedInviteUser { + private String username; + private String password; + private String displayName; + private String idCard; + private Integer gender; + private String errorMsg; + } + + private PreparedInviteUser prepareInviteUser(DeptInviteRegisterDTO dto, String phone) { + PreparedInviteUser prepared = new PreparedInviteUser(); + boolean phoneExists = sysUserMapper.exists(Wrappers.lambdaQuery().eq(SysUser::getPhone, phone)); + if (phoneExists) { + prepared.errorMsg = MsgUtils.getMessage(ErrorCodes.SYS_USER_PHONE_EXISTING, phone); + return prepared; } - // 账号默认用手机号前缀 u,兼容旧客户端显式传 username/password String username = StrUtil.blankToDefault(StrUtil.trim(dto.getUsername()), "u" + phone); - String usernameErr = validateUsername(username); - if (usernameErr != null) { - // 纯数字账号不符合「字母开头」规则时,强制用 u+手机号 + if (validateUsername(username) != null) { username = "u" + phone; } boolean usernameExists = sysUserMapper.exists(Wrappers.lambdaQuery().eq(SysUser::getUsername, username)); @@ -325,77 +462,87 @@ public class SysDeptInviteServiceImpl extends ServiceImpl roleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET); + private void assignDefaultRolesAndIdentity(SysUser sysUser, String identityType, String... extraRoleCodes) { + List roleCodes = new java.util.ArrayList<>( + List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET)); + if (extraRoleCodes != null) { + for (String c : extraRoleCodes) { + if (StrUtil.isNotBlank(c) && !roleCodes.contains(c)) { + roleCodes.add(c.trim()); + } + } + } List defaultRoles = Optional.ofNullable(sysRoleService.findRolesByCodes(roleCodes)) .orElseGet(Collections::emptyList); Map roleIdByCode = defaultRoles.stream() .collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getRoleId, (a, b) -> a)); - if (!roleIdByCode.keySet().containsAll(roleCodes)) { + if (!roleIdByCode.containsKey(CommonRoleConstant.DEFAULT_USER) + || !roleIdByCode.containsKey(CommonRoleConstant.AUTO_CREATE_WALLET)) { throw new IllegalStateException("默认用户角色配置不完整,请联系管理员配置 GENERAL_USER / WALLET_AUTO_CREATE"); } for (String code : roleCodes) { + Long roleId = roleIdByCode.get(code); + if (roleId == null) { + log.warn("|{}|邀请注册缺少角色 {},已跳过(请先执行分销工作台 SQL)|userId={}|", TAG, code, + sysUser.getUserId()); + continue; + } SysUserRole userRole = new SysUserRole(); userRole.setUserId(sysUser.getUserId()); - userRole.setRoleId(roleIdByCode.get(code)); + userRole.setRoleId(roleId); sysUserRoleMapper.insert(userRole); } UserDTO identityDto = new UserDTO(); identityDto.setIdentityTypes(List.of(identityType)); sysUserIdentityService.saveUserIdentity(sysUser.getUserId(), identityDto); - - if (needAudit) { - startInviteUserFlow(sysUser, invite.getDeptId()); - return R.ok(true, "注册成功,请等待审核通过后再登录"); - } - - // 事务提交后再异步开钱包(带租户),避免 CompletableFuture 抢跑读不到新用户/丢租户头 - applicationEventPublisher.publishEvent( - new UserWalletCreateEvent(sysUser.getUserId(), TenantContextHolder.getTenantId())); - // 保留验证码供前端立即短信登录(登录时再消费) - return R.ok(true, "注册成功"); } /** @@ -500,7 +647,7 @@ public class SysDeptInviteServiceImpl extends ServiceImpl Date: Thu, 12 Nov 2026 20:03:44 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SmHotelDownstreamChannelConfigDTO.java | 5 ++++- .../config/HotelDownstreamProperties.java | 10 +++++----- .../huanqi/HuanqiDownstreamHotelAdapter.java | 20 ++++++++++++++----- .../impl/SmHotelChannelConfigServiceImpl.java | 3 +++ 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelDownstreamChannelConfigDTO.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelDownstreamChannelConfigDTO.java index 7867f05ed..6dfa8df31 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelDownstreamChannelConfigDTO.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelDownstreamChannelConfigDTO.java @@ -53,7 +53,7 @@ public class SmHotelDownstreamChannelConfigDTO { @Schema(description = "hotel/apartment 每分钟上限,默认 600;≤0 关闭") private Integer rateLimitHotelApartmentPerMin; - @Schema(description = "product/daily 每分钟上限,默认 300;≤0 关闭") + @Schema(description = "product/daily 每分钟上限,默认 150(环启 2026-10-26);≤0 关闭") private Integer rateLimitProductDailyPerMin; @Schema(description = "慧订 hoteldatepush 出站 QPS,默认 5;≤0 关闭") @@ -112,4 +112,7 @@ public class SmHotelDownstreamChannelConfigDTO { @Schema(description = "本地改价/库存/房态后是否自动推本伙伴;大批量补数可临时 false,默认 true(未配则跟进程级)") private Boolean resourceAutoPushEnabled; + + @Schema(description = "全量/日更推送酒店并发;环启建议 ≤3,默认 2;1=串行") + private Integer resourcePushConcurrency; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java index 2b8023aa7..c8b8a317e 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java @@ -67,8 +67,8 @@ public class HotelDownstreamProperties { /** /hotel/sync、/apartment/sync 每分钟上限(环启文档 600) */ private int rateLimitHotelApartmentPerMin = 600; - /** /product/sync、/daily/sync 每分钟上限(环启文档 300) */ - private int rateLimitProductDailyPerMin = 300; + /** /product/sync、/daily/sync 每分钟上限(环启文档 2026-10-26 起 150) */ + private int rateLimitProductDailyPerMin = 150; /** * 慧订 /splider/hoteldatepush 出站 QPS(对方已限流)。 @@ -193,8 +193,8 @@ public class HotelDownstreamProperties { private String resourcePushCron = "0 30 2 * * ?"; /** - * 下游全量推酒店并发(圣诺等 HTTP 出站)。1=串行;建议 4~8。 - * 对方限流时报错再调低。伙伴 YAML 可覆盖。 + * 下游全量推酒店并发。1=串行。 + * 环启文档建议并发≤3(产品/日历写重);默认 2。圣诺若对方宽松可在 YAML 调高。 */ - private int resourcePushConcurrency = 6; + private int resourcePushConcurrency = 2; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huanqi/HuanqiDownstreamHotelAdapter.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huanqi/HuanqiDownstreamHotelAdapter.java index 309671875..08b3afb7b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huanqi/HuanqiDownstreamHotelAdapter.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huanqi/HuanqiDownstreamHotelAdapter.java @@ -187,14 +187,17 @@ public class HuanqiDownstreamHotelAdapter implements DownstreamHotelAdapter { } } - /** 按 appKey + 接口组做分钟窗限流(多实例共享 Redis) */ + /** + * 按 appKey + 接口组做分钟窗限流(多实例共享 Redis)。 + * 超限时必须 decrement 退回占用并等待,禁止「等超时仍继续」——否则会把对方打到「访问过于频繁」。 + */ private void acquireRatePermit(HotelDownstreamProperties props, String path) { int limit = resolveRateLimit(props, path); if (limit <= 0 || StrUtil.isBlank(props.getAppKey())) { return; } String group = rateGroup(path); - for (int i = 0; i < 60; i++) { + for (int i = 0; i < 120; i++) { long minute = System.currentTimeMillis() / 60_000L; String key = RATE_KEY + props.getAppKey() + ":" + group + ":" + minute; Long n = redisTemplate.opsForValue().increment(key); @@ -204,10 +207,17 @@ public class HuanqiDownstreamHotelAdapter implements DownstreamHotelAdapter { if (n == null || n <= limit) { return; } - long wait = 60_000L - (System.currentTimeMillis() % 60_000L) + 20L; - sleepQuiet(Math.min(wait, 5000L)); + // 超限:退回本次数,避免循环里反复 increment 把窗口计数打爆 + try { + redisTemplate.opsForValue().decrement(key); + } + catch (Exception ignored) { + // ignore + } + long wait = 60_000L - (System.currentTimeMillis() % 60_000L) + 80L; + sleepQuiet(Math.min(Math.max(wait, 200L), 3000L)); } - log.warn("|{}|限流等待超时仍继续|appKey={}|path={}|limit={}|", TAG, props.getAppKey(), path, limit); + throw new CheckedException("下游限流等待超时(" + group + "≤" + limit + "/min),请降低推送并发或错峰再试"); } private static int resolveRateLimit(HotelDownstreamProperties props, String path) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelChannelConfigServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelChannelConfigServiceImpl.java index a006654ac..5a8a5d280 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelChannelConfigServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelChannelConfigServiceImpl.java @@ -409,6 +409,9 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl 0) { + target.setResourcePushConcurrency(dto.getResourcePushConcurrency()); + } } private void overlayShangyou(ShangyouHotelProperties target, SmHotelShangyouChannelConfigDTO dto) { From c7fa112451c04003eafc57e90d85f3b665ce54c5 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Thu, 12 Nov 2026 20:22:22 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/SmHotelController.java | 50 +++++++++++++++++-- .../supermarket/service/SmHotelService.java | 6 +++ .../service/impl/SmHotelServiceImpl.java | 25 ++++++++-- 3 files changed, 73 insertions(+), 8 deletions(-) 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 0c969d746..16055a53f 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 @@ -47,16 +47,23 @@ public class SmHotelController { @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R> page(@ParameterObject Page page, @ParameterObject SmHotelEntity query) { + String nameKw = StrUtil.trim(query.getHotelName()); + boolean nameSearch = StrUtil.isNotBlank(nameKw); var wrapper = Wrappers.lambdaQuery() - .like(StrUtil.isNotBlank(query.getHotelName()), SmHotelEntity::getHotelName, query.getHotelName()) .like(StrUtil.isNotBlank(query.getHotelCode()), SmHotelEntity::getHotelCode, query.getHotelCode()) .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()) - // 默认按主键倒序:雪花 ID 近似创建时间,可走索引;避免无索引时 create_time 全表 filesort - .orderByDesc(SmHotelEntity::getId); + .eq(query.getDeptId() != null, SmHotelEntity::getDeptId, query.getDeptId()); + // 店名:先走前缀(可用 idx_hotel_name);含匹配去掉 ORDER BY + 不做 COUNT,避免十几万酒店超时 + if (nameSearch) { + wrapper.and(w -> w.likeRight(SmHotelEntity::getHotelName, nameKw) + .or() + .eq(SmHotelEntity::getHotelCode, nameKw) + .or() + .eq(SmHotelEntity::getSHotelId, nameKw)); + } // 外部酒店ID/eid:推圣诺 getInfo 用 sHotelId,空时回落 hotelCode if (StrUtil.isNotBlank(query.getSHotelId())) { String eid = query.getSHotelId().trim(); @@ -74,7 +81,40 @@ public class SmHotelController { .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")); } hotelService.applySaleableFilter(wrapper, query.getSaleable()); - return R.ok(hotelService.pageScope(page, wrapper)); + if (!nameSearch) { + // 默认按主键倒序:雪花 ID 近似创建时间,可走索引 + wrapper.orderByDesc(SmHotelEntity::getId); + return R.ok(hotelService.pageScope(page, wrapper)); + } + // 名称搜索:先前缀(可排序);无结果再含匹配且不做 COUNT / 不 ORDER BY + wrapper.orderByDesc(SmHotelEntity::getId); + Page prefixPage = hotelService.pageScopeWithoutCount(page, wrapper); + if (page.getCurrent() > 1 || (prefixPage.getRecords() != null && !prefixPage.getRecords().isEmpty())) { + return R.ok(prefixPage); + } + var contains = Wrappers.lambdaQuery() + .like(SmHotelEntity::getHotelName, nameKw) + .like(StrUtil.isNotBlank(query.getHotelCode()), SmHotelEntity::getHotelCode, query.getHotelCode()) + .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()); + if (StrUtil.isNotBlank(query.getSHotelId())) { + String eid = query.getSHotelId().trim(); + contains.and(w -> w.eq(SmHotelEntity::getSHotelId, eid).or().eq(SmHotelEntity::getHotelCode, eid)); + } + HotelAreaAdcodeUtil.applyHotelListFilter(contains, query.getDistrictAdcode(), query.getDistrict()); + if ("OFFLINE".equalsIgnoreCase(query.getUpstreamStatus())) { + contains.eq(SmHotelEntity::getUpstreamStatus, "OFFLINE"); + } + else if ("ACTIVE".equalsIgnoreCase(query.getUpstreamStatus())) { + contains.and(w -> w.isNull(SmHotelEntity::getUpstreamStatus) + .or() + .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")); + } + hotelService.applySaleableFilter(contains, query.getSaleable()); + // 含匹配:禁止 ORDER BY,让 MySQL 凑够 LIMIT 即可停,避免全表 filesort + return R.ok(hotelService.pageScopeWithoutCount(page, contains)); } /** 上传文件会立刻 transferTo 临时目录,勿加 @SysLog */ diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelService.java index 8c506e5d0..efe0b409e 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelService.java @@ -12,6 +12,12 @@ public interface SmHotelService extends IService { Page pageScope(Page page, LambdaQueryWrapper wrapper); + /** + * 同 {@link #pageScope},但不做 COUNT(*)(店名模糊搜索时 COUNT+LIKE 会扫十几万行超时)。 + * total 用 hasMore 估算,便于前端翻页。 + */ + Page pageScopeWithoutCount(Page page, LambdaQueryWrapper wrapper); + /** * 管理端列表可售筛选:1/SALEABLE=近窗有开房可售库存;0/UNSALEABLE=近窗无可售。 */ diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java index 2c2c17733..e9a988fe9 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java @@ -76,8 +76,29 @@ public class SmHotelServiceImpl extends ServiceImpl pageScope(Page page, LambdaQueryWrapper wrapper) { + applyListSelectAndScope(wrapper); + return baseMapper.selectPage(page, wrapper); + } + + @Override + public Page pageScopeWithoutCount(Page page, + LambdaQueryWrapper wrapper) { + applyListSelectAndScope(wrapper); + long size = Math.max(1L, Math.min(page.getSize(), 100L)); + long current = Math.max(1L, page.getCurrent()); + Page q = new Page<>(current, size + 1, false); + Page raw = baseMapper.selectPage(q, wrapper); + java.util.List rows = raw.getRecords() == null ? java.util.List.of() : raw.getRecords(); + boolean hasMore = rows.size() > size; + java.util.List slice = hasMore ? rows.subList(0, (int) size) : rows; + long total = (current - 1) * size + slice.size() + (hasMore ? 1 : 0); + Page out = new Page<>(current, size, total); + out.setRecords(new java.util.ArrayList<>(slice)); + return out; + } + + private void applyListSelectAndScope(LambdaQueryWrapper wrapper) { // 不再聚合房型/产品/日历数;列表只查展示列,丢掉 intro/轮播等大字段减轻 IO - // coverImage 为短路径字符串,卡片封面需要;carouselImages/intro 仍不查 wrapper.select(SmHotelEntity::getId, SmHotelEntity::getTenantId, SmHotelEntity::getDeptId, SmHotelEntity::getSourceId, SmHotelEntity::getGroupCode, SmHotelEntity::getHotelCode, SmHotelEntity::getSGroupId, SmHotelEntity::getSHotelId, SmHotelEntity::getHotelName, @@ -88,9 +109,7 @@ public class SmHotelServiceImpl extends ServiceImpl Date: Thu, 12 Nov 2026 20:45:35 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/SmHotelMgrPushConfigEntity.java | 3 + .../impl/SmHotelMgrPushServiceImpl.java | 159 +++++++++++++----- 2 files changed, 122 insertions(+), 40 deletions(-) diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelMgrPushConfigEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelMgrPushConfigEntity.java index 7a93bfe64..c41c94e6b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelMgrPushConfigEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelMgrPushConfigEntity.java @@ -33,6 +33,9 @@ public class SmHotelMgrPushConfigEntity extends Model leaders = deptId == null ? List.of() : resolveDeptLeaders(deptId); + List admins = adminEnabled ? resolveUsersByRoleCode(CommonRoleConstant.ROLE_ADMIN) : List.of(); + LinkedHashSet recipients = new LinkedHashSet<>(); + recipients.addAll(leaders); + recipients.addAll(admins); + List logDepts = deptId == null ? List.of() : List.of(deptId); + String deptName = deptId == null ? "" : resolveDeptNames(logDepts); + if (recipients.isEmpty()) { + String tip; + if (deptId == null && !adminEnabled) { + tip = "酒店未绑定部门,且未开启「推送管理员」。请配置酒店所属部门或开启管理员推送。"; + } + else if (deptId == null) { + tip = "酒店未绑定部门,且未找到 ROLE_ADMIN 用户。"; + } + else if (CollUtil.isEmpty(leaders) && !adminEnabled) { + tip = "部门「" + StrUtil.blankToDefault(deptName, String.valueOf(deptId)) + + "」下没有岗位=部门负责人(TEAM_LEADER)的用户(仅本部门)。" + + "请确认用户所属部门与岗位,或开启「推送管理员」。"; + } + else if (CollUtil.isEmpty(leaders)) { + tip = "部门「" + StrUtil.blankToDefault(deptName, String.valueOf(deptId)) + + "」无部门负责人,且未找到 ROLE_ADMIN 用户。"; + } + else { + tip = "未找到可推送对象。"; + } + log.info("|酒店管理推送|新订单无接收人|orderId={}|deptId={}|adminEnabled={}|", orderId, deptId, + adminEnabled); savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId), - order.getHotelId(), hotelName, List.of(), List.of(), 0, 0, title, null, tip); - return force ? PushResult.fail(tip) : PushResult.skip(tip); - } - List leaders = resolveDeptLeaders(deptId); - if (CollUtil.isEmpty(leaders)) { - String tip = "部门 " + deptId + " 下没有岗位=部门负责人(TEAM_LEADER)的用户(仅本部门,不含下级)。" - + "请到用户管理确认:用户所属部门=该酒店部门,且岗位勾选「部门负责人」。"; - log.info("|酒店管理推送|新订单无负责人|orderId={}|deptId={}|hotelId={}|", orderId, deptId, - order.getHotelId()); - savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId), - order.getHotelId(), hotelName, List.of(deptId), List.of(), 0, 0, title, null, tip); + order.getHotelId(), hotelName, logDepts, List.of(), 0, 0, title, null, tip); return force ? PushResult.fail(tip) : PushResult.skip(tip); } Long tenantId = resolveTenantId(order.getTenantId(), hotel == null ? null : hotel.getTenantId()); - String channelNoVal = StrUtil.blankToDefault(order.getChannelNo(), String.valueOf(orderId)); String content = contentTpl.replace("{hotelName}", hotelName).replace("{channelNo}", channelNoVal); if (!content.contains(hotelName) && !contentTpl.contains("{hotelName}")) { content = hotelName + ":" + content; @@ -234,6 +254,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { payload.put("channelNo", channelNoVal); payload.put("deptId", deptId); payload.put("force", force); + payload.put("adminPush", adminEnabled); Map linkParams = new HashMap<>(4); linkParams.put("orderId", String.valueOf(orderId)); String eventPrefix = force @@ -241,7 +262,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { : ("HOTEL_MGR_ORDER:" + orderId); int ok = 0; List failHints = new ArrayList<>(); - for (Long userId : leaders) { + for (Long userId : recipients) { if (userId == null) { continue; } @@ -264,26 +285,28 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { ok++; } else if (failHints.size() < 3) { - failHints.add("userId=" + userId + ":" + err); + failHints.add(displayUserName(userId) + ":" + err); } } - int fail = Math.max(0, leaders.size() - ok); - log.info("|酒店管理推送|新订单完成|orderId={}|deptId={}|leaders={}|ok={}|force={}|leadersIds={}|", - orderId, deptId, leaders.size(), ok, force, leaders); + List recipientList = new ArrayList<>(recipients); + int fail = Math.max(0, recipientList.size() - ok); + String leaderNames = resolveUserNames(leaders); + String adminNames = resolveUserNames(admins); + String detail = buildNewOrderDetail(orderLabel, deptName, leaderNames, adminNames, adminEnabled); + log.info("|酒店管理推送|新订单完成|orderId={}|deptId={}|recipients={}|ok={}|force={}|admin={}|", + orderId, deptId, recipientList.size(), ok, force, adminEnabled); if (ok == 0) { - String tip = "找到负责人 " + leaders.size() + " 人(deptId=" + deptId + ", userIds=" + leaders - + "),但消息中心投递全部失败。" - + (failHints.isEmpty() ? "请确认 thirdparty 已重启并包含酒店管理推送接口。" - : ("原因:" + String.join(";", failHints))); + String tip = "找到接收人 " + recipientList.size() + " 人,但消息中心投递全部失败。" + detail + + (failHints.isEmpty() ? " 请确认 thirdparty 已重启并包含酒店管理推送接口。" + : (" 原因:" + String.join(";", failHints))); savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId), - order.getHotelId(), hotelName, List.of(deptId), leaders, 0, fail, title, content, tip); + order.getHotelId(), hotelName, logDepts, recipientList, 0, fail, title, content, tip); return force ? PushResult.fail(tip) : PushResult.skip(tip); } - String msg = "已推送成功 " + ok + "/" + leaders.size() + " 人(订单ID=" + orderId + ",deptId=" + deptId - + ",负责人=" + leaders + ")"; + String msg = "已推送成功 " + ok + "/" + recipientList.size() + " 人" + detail; savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId), - order.getHotelId(), hotelName, List.of(deptId), leaders, ok, fail, title, content, msg); - return PushResult.success(ok, leaders.size(), msg); + order.getHotelId(), hotelName, logDepts, recipientList, ok, fail, title, content, msg); + return PushResult.success(ok, recipientList.size(), msg); } catch (Exception e) { log.warn("|酒店管理推送|新订单异常|orderId={}|err={}|", orderId, e.getMessage(), e); @@ -324,8 +347,9 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { } } if (leaders.isEmpty()) { + List sampleDepts = deptIds.stream().limit(5).toList(); String tip = "扫描到 " + deptIds.size() + " 个酒店部门,但都没有岗位=部门负责人(TEAM_LEADER)的用户。" - + "部门示例:" + deptIds.stream().limit(5).map(String::valueOf).collect(Collectors.joining(",")); + + "部门示例:" + StrUtil.blankToDefault(resolveDeptNames(sampleDepts), "无"); log.info("|酒店管理推送|日历提醒无负责人|hour={}|deptCount={}|", hour, deptIds.size()); savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null, new ArrayList<>(deptIds), List.of(), 0, 0, title, content, tip); @@ -362,26 +386,80 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { ok++; } else if (failHints.size() < 3) { - failHints.add("userId=" + userId + ":" + err); + failHints.add(displayUserName(userId) + ":" + err); } } int fail = Math.max(0, leaders.size() - ok); - log.info("|酒店管理推送|日历提醒完成|hour={}|deptCount={}|leaders={}|ok={}|ids={}|", hour, deptIds.size(), - leaders.size(), ok, leaders); + List leaderList = new ArrayList<>(leaders); + String leaderNames = resolveUserNames(leaderList); + log.info("|酒店管理推送|日历提醒完成|hour={}|deptCount={}|leaders={}|ok={}|", hour, deptIds.size(), + leaders.size(), ok); List logDepts = deptsWithLeader.isEmpty() ? new ArrayList<>(deptIds) : deptsWithLeader; if (ok == 0) { - String tip = "找到负责人 " + leaders.size() + " 人,但投递全部失败。" + String tip = "找到负责人 " + leaders.size() + " 人(" + leaderNames + "),但投递全部失败。" + (failHints.isEmpty() ? "请确认 thirdparty 已重启。" : ("原因:" + String.join(";", failHints))); savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null, - logDepts, new ArrayList<>(leaders), 0, fail, title, content, tip); + logDepts, leaderList, 0, fail, title, content, tip); return force ? PushResult.fail(tip) : PushResult.skip(tip); } - String msg = "日历提醒成功 " + ok + "/" + leaders.size() + " 人(负责人=" + leaders + ")"; + String msg = "日历提醒成功 " + ok + "/" + leaders.size() + " 人(负责人=" + leaderNames + ")"; savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null, - logDepts, new ArrayList<>(leaders), ok, fail, title, content, msg); + logDepts, leaderList, ok, fail, title, content, msg); return PushResult.success(ok, leaders.size(), msg); } + private static String buildNewOrderDetail(String orderLabel, String deptName, String leaderNames, + String adminNames, boolean adminEnabled) { + StringBuilder sb = new StringBuilder(); + sb.append("(订单=").append(StrUtil.blankToDefault(orderLabel, "-")); + if (StrUtil.isNotBlank(deptName)) { + sb.append(",部门=").append(deptName); + } + if (StrUtil.isNotBlank(leaderNames)) { + sb.append(",负责人=").append(leaderNames); + } + if (adminEnabled) { + sb.append(",管理员=").append(StrUtil.blankToDefault(adminNames, "无")); + } + sb.append(")"); + return sb.toString(); + } + + /** 按角色编码取当前租户有效用户,失败返回空列表 */ + private List resolveUsersByRoleCode(String roleCode) { + if (StrUtil.isBlank(roleCode)) { + return List.of(); + } + try { + R> resp = remoteUserService.getInnerUserIdsByRoleCode(roleCode); + if (resp == null || !resp.isOk() || CollUtil.isEmpty(resp.getData())) { + log.info("|酒店管理推送|角色无用户|roleCode={}|code={}|msg={}|", roleCode, + resp == null ? null : resp.getCode(), resp == null ? "null" : resp.getMsg()); + return List.of(); + } + List ids = new ArrayList<>(); + for (Object o : resp.getData()) { + Long id = toLong(o); + if (id != null) { + ids.add(id); + } + } + return ids.stream().distinct().toList(); + } + catch (Exception e) { + log.warn("|酒店管理推送|查角色用户异常|roleCode={}|err={}|", roleCode, e.getMessage()); + return List.of(); + } + } + + private String displayUserName(Long userId) { + if (userId == null) { + return "-"; + } + String names = resolveUserNames(List.of(userId)); + return StrUtil.blankToDefault(names, String.valueOf(userId)); + } + private void savePushLog(String pushType, String triggerType, String bizId, Long hotelId, String hotelName, List deptIds, List userIds, int success, int fail, String title, String content, String resultMsg) { @@ -555,6 +633,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { row.setNewOrderEnabled(ENABLED); row.setNewOrderTitle(DEFAULT_NEW_TITLE); row.setNewOrderContent(DEFAULT_NEW_CONTENT); + row.setNewOrderAdminEnabled(ENABLED); row.setCalendarRemindEnabled(ENABLED); row.setCalendarRemindTitle(DEFAULT_CAL_TITLE); row.setCalendarRemindContent(DEFAULT_CAL_CONTENT); From b8a868e0bb27e404a4fda0ff5c9a80d128f4a410 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Thu, 12 Nov 2026 20:53:04 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/SmHotelMgrPushLogEntity.java | 3 + .../impl/SmHotelMgrPushServiceImpl.java | 77 ++++++++++++++++--- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelMgrPushLogEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelMgrPushLogEntity.java index b6aa76e6c..9191fb1b4 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelMgrPushLogEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelMgrPushLogEntity.java @@ -79,6 +79,9 @@ public class SmHotelMgrPushLogEntity extends Model { private String resultMsg; + @Schema(description = "下游请求体快照(失败/部分失败时写入 JSON)") + private String requestJson; + @TableField(fill = FieldFill.INSERT) private String createBy; diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMgrPushServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMgrPushServiceImpl.java index b4661422a..1cf73a1c3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMgrPushServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMgrPushServiceImpl.java @@ -240,7 +240,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { log.info("|酒店管理推送|新订单无接收人|orderId={}|deptId={}|adminEnabled={}|", orderId, deptId, adminEnabled); savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId), - order.getHotelId(), hotelName, logDepts, List.of(), 0, 0, title, null, tip); + order.getHotelId(), hotelName, logDepts, List.of(), 0, 0, title, null, tip, null); return force ? PushResult.fail(tip) : PushResult.skip(tip); } Long tenantId = resolveTenantId(order.getTenantId(), hotel == null ? null : hotel.getTenantId()); @@ -262,6 +262,8 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { : ("HOTEL_MGR_ORDER:" + orderId); int ok = 0; List failHints = new ArrayList<>(); + List> failRequests = new ArrayList<>(); + HotelManagerPushEventDto sampleRequest = null; for (Long userId : recipients) { if (userId == null) { continue; @@ -280,12 +282,20 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { dto.setDeepLinkPath("/pages/hotel-manage/orders"); dto.setDeepLinkParamsJson(JSONUtil.toJsonStr(linkParams)); dto.setOccurredTime(LocalDateTime.now()); + if (sampleRequest == null) { + sampleRequest = dto; + } String err = pushOne(dto); if (err == null) { ok++; } - else if (failHints.size() < 3) { - failHints.add(displayUserName(userId) + ":" + err); + else { + if (failHints.size() < 3) { + failHints.add(displayUserName(userId) + ":" + err); + } + if (failRequests.size() < 5) { + failRequests.add(buildFailRequestItem(displayUserName(userId), err, dto)); + } } } List recipientList = new ArrayList<>(recipients); @@ -293,6 +303,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { String leaderNames = resolveUserNames(leaders); String adminNames = resolveUserNames(admins); String detail = buildNewOrderDetail(orderLabel, deptName, leaderNames, adminNames, adminEnabled); + String requestJson = buildRequestSnapshot(sampleRequest, failRequests, ok, fail); log.info("|酒店管理推送|新订单完成|orderId={}|deptId={}|recipients={}|ok={}|force={}|admin={}|", orderId, deptId, recipientList.size(), ok, force, adminEnabled); if (ok == 0) { @@ -300,12 +311,14 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { + (failHints.isEmpty() ? " 请确认 thirdparty 已重启并包含酒店管理推送接口。" : (" 原因:" + String.join(";", failHints))); savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId), - order.getHotelId(), hotelName, logDepts, recipientList, 0, fail, title, content, tip); + order.getHotelId(), hotelName, logDepts, recipientList, 0, fail, title, content, tip, + requestJson); return force ? PushResult.fail(tip) : PushResult.skip(tip); } String msg = "已推送成功 " + ok + "/" + recipientList.size() + " 人" + detail; savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId), - order.getHotelId(), hotelName, logDepts, recipientList, ok, fail, title, content, msg); + order.getHotelId(), hotelName, logDepts, recipientList, ok, fail, title, content, msg, + requestJson); return PushResult.success(ok, recipientList.size(), msg); } catch (Exception e) { @@ -352,7 +365,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { + "部门示例:" + StrUtil.blankToDefault(resolveDeptNames(sampleDepts), "无"); log.info("|酒店管理推送|日历提醒无负责人|hour={}|deptCount={}|", hour, deptIds.size()); savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null, - new ArrayList<>(deptIds), List.of(), 0, 0, title, content, tip); + new ArrayList<>(deptIds), List.of(), 0, 0, title, content, tip, null); return force ? PushResult.fail(tip) : PushResult.skip(tip); } Long tenantId = resolveTenantId(null, hotels.get(0).getTenantId()); @@ -365,6 +378,8 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { : ("HOTEL_MGR_CAL:" + day + ":" + hour); int ok = 0; List failHints = new ArrayList<>(); + List> failRequests = new ArrayList<>(); + HotelManagerPushEventDto sampleRequest = null; for (Long userId : leaders) { if (userId == null) { continue; @@ -381,17 +396,26 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { dto.setPayloadJson(JSONUtil.toJsonStr(payload)); dto.setDeepLinkPath("/pages/hotel-manage/list"); dto.setOccurredTime(LocalDateTime.now()); + if (sampleRequest == null) { + sampleRequest = dto; + } String err = pushOne(dto); if (err == null) { ok++; } - else if (failHints.size() < 3) { - failHints.add(displayUserName(userId) + ":" + err); + else { + if (failHints.size() < 3) { + failHints.add(displayUserName(userId) + ":" + err); + } + if (failRequests.size() < 5) { + failRequests.add(buildFailRequestItem(displayUserName(userId), err, dto)); + } } } int fail = Math.max(0, leaders.size() - ok); List leaderList = new ArrayList<>(leaders); String leaderNames = resolveUserNames(leaderList); + String requestJson = buildRequestSnapshot(sampleRequest, failRequests, ok, fail); log.info("|酒店管理推送|日历提醒完成|hour={}|deptCount={}|leaders={}|ok={}|", hour, deptIds.size(), leaders.size(), ok); List logDepts = deptsWithLeader.isEmpty() ? new ArrayList<>(deptIds) : deptsWithLeader; @@ -399,12 +423,12 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { String tip = "找到负责人 " + leaders.size() + " 人(" + leaderNames + "),但投递全部失败。" + (failHints.isEmpty() ? "请确认 thirdparty 已重启。" : ("原因:" + String.join(";", failHints))); savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null, - logDepts, leaderList, 0, fail, title, content, tip); + logDepts, leaderList, 0, fail, title, content, tip, requestJson); return force ? PushResult.fail(tip) : PushResult.skip(tip); } String msg = "日历提醒成功 " + ok + "/" + leaders.size() + " 人(负责人=" + leaderNames + ")"; savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null, - logDepts, leaderList, ok, fail, title, content, msg); + logDepts, leaderList, ok, fail, title, content, msg, requestJson); return PushResult.success(ok, leaders.size(), msg); } @@ -460,9 +484,39 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { return StrUtil.blankToDefault(names, String.valueOf(userId)); } + /** + * 组装下游排查快照:有失败时优先写失败请求;全部成功也保留 1 条样例请求便于对照。 + */ + private static String buildRequestSnapshot(HotelManagerPushEventDto sample, List> failItems, + int ok, int fail) { + if (sample == null && CollUtil.isEmpty(failItems)) { + return null; + } + Map root = new HashMap<>(8); + root.put("api", "thirdparty receiveHotelManagerPushEvent"); + root.put("successCount", ok); + root.put("failCount", fail); + if (CollUtil.isNotEmpty(failItems)) { + root.put("fails", failItems); + } + else if (sample != null) { + root.put("sampleRequest", sample); + } + return StrUtil.maxLength(JSONUtil.toJsonStr(root), 60000); + } + + private static Map buildFailRequestItem(String recipientName, String error, + HotelManagerPushEventDto dto) { + Map item = new HashMap<>(4); + item.put("recipientName", recipientName); + item.put("error", error); + item.put("request", dto); + return item; + } + private void savePushLog(String pushType, String triggerType, String bizId, Long hotelId, String hotelName, List deptIds, List userIds, int success, int fail, String title, String content, - String resultMsg) { + String resultMsg, String requestJson) { try { SmHotelMgrPushLogEntity row = new SmHotelMgrPushLogEntity(); row.setPushType(pushType); @@ -479,6 +533,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { row.setTitle(title); row.setContent(StrUtil.maxLength(content, 500)); row.setResultMsg(StrUtil.maxLength(resultMsg, 900)); + row.setRequestJson(requestJson); if (success > 0 && fail <= 0) { row.setStatus(SmHotelMgrPushLogEntity.STATUS_OK); } From 0c17f14505b6fe830dacd6353cf07c50c07680ab Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Thu, 12 Nov 2026 21:58:04 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/SmHotelSyncServiceImpl.java | 78 +++++++++++++++++-- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java index 28afa60c5..689be1b3e 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java @@ -97,6 +97,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { /** 暂停续跑检查点:idx:已处理条数 或 lid:详情拉取 lastId */ private static final String RESUME_CKPT_KEY = "sm:hotel:sync:resume:"; + /** + * 下游全量推/失败重推的酒店 ID 快照(逗号分隔)。续跑必须用这份列表, + * 不能再按「渠道全量授权酒店」重切,否则 RETRY 目标 142 却继续推成上千家。 + */ + private static final String RESUME_HOTELS_KEY = "sm:hotel:sync:hotels:"; + /** 同步日志超过该时间无 progress 心跳,视为死任务 */ private static final int STALE_RUNNING_MINUTES = 30; @@ -506,6 +512,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { logEntity.setSkipCount(droppedNoEid); } syncLogMapper.insert(logEntity); + saveResumeHotelIds(logEntity.getId(), hotelIds); Long tenantId = TenantContextHolder.getTenantId(); if (tenantId == null) { @@ -596,6 +603,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { logEntity.setProgressPct(1); logEntity.setLocalCount((long) hotelIds.size()); syncLogMapper.insert(logEntity); + saveResumeHotelIds(logEntity.getId(), hotelIds); Long tenantId = TenantContextHolder.getTenantId(); if (tenantId == null) { @@ -760,8 +768,15 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { final Long logId = e.getId(); if (TYPE_DOWNSTREAM_PUSH.equals(type)) { String partner = SmHotelDataSource.normalize(e.getDataSource()).toLowerCase(); - List hotelIds = channelDeptService.listHotelIdsForDownstreamPartner(partner); - hotelIds = filterShengnuoHotelsRequireEid(partner, hotelIds); + // 优先用开跑时快照(RETRY 只有失败子集);没有快照再回退渠道全量 + List hotelIds = readResumeHotelIds(logId); + boolean fromSnapshot = CollUtil.isNotEmpty(hotelIds); + if (!fromSnapshot) { + hotelIds = channelDeptService.listHotelIdsForDownstreamPartner(partner); + hotelIds = filterShengnuoHotelsRequireEid(partner, hotelIds); + log.warn("|下游全量推送|续跑无酒店快照,回退渠道全量|logId={}|partner={}|trigger={}|size={}|", + logId, partner, e.getTriggerType(), hotelIds == null ? 0 : hotelIds.size()); + } final int baseOk = nullToZero(e.getSuccessCount()); final int baseFail = nullToZero(e.getFailCount()); final int baseSkip = nullToZero(e.getSkipCount()); @@ -777,11 +792,19 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { final List ids = List.copyOf(hotelIds == null ? List.of() : hotelIds); final int baseIdx = resumeIdx; final List baseFails = parseFailLines(e.getFailDetail()); - final long totalHint = e.getLocalCount() == null ? (baseIdx + ids.size()) : e.getLocalCount(); + // 目标分母:有快照时用开跑时 localCount;无快照回退全量时按实际剩余校正,避免「目标142、已处理350」 + int planned = e.getLocalCount() == null ? 0 : e.getLocalCount().intValue(); + int totalHint = Math.max(planned, baseIdx + ids.size()); + if (!fromSnapshot && planned > 0 && totalHint > planned) { + log.warn("|下游全量推送|续跑校正目标|logId={}|oldTarget={}|newTarget={}|remaining={}|", logId, + planned, totalHint, ids.size()); + } + final int totalFinal = totalHint; EXECUTOR.execute(() -> TenantBroker.runAs(tid, x -> runDownstreamScopePush(logId, partnerFinal, ids, baseIdx, baseOk, baseFail, baseSkip, - baseFails, (int) totalHint))); - return R.ok(e, "已继续下游全量推,从第 " + (baseIdx + 1) + " 家接着跑"); + baseFails, totalFinal))); + return R.ok(e, "已继续下游全量推,从第 " + (baseIdx + 1) + " 家接着跑(目标 " + + totalFinal + ")"); } if (TYPE_DETAIL_SCOPE.equals(type) || TYPE_DETAIL_NEAR.equals(type)) { @@ -1412,7 +1435,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { HotelDownstreamProperties dsProps = channelConfigService.resolveDownstreamRuntime(partnerCode); int sampleMax = dsProps == null ? 20 : Math.max(0, dsProps.getPushLogSampleSuccessMax()); int concurrency = dsProps == null ? 6 : Math.max(1, dsProps.getResourcePushConcurrency()); - int total = totalAll > 0 ? totalAll : (baseProcessed + (hotelIds == null ? 0 : hotelIds.size())); + int pending = hotelIds == null ? 0 : hotelIds.size(); + // 分母至少覆盖「已处理 + 本段待推」,避免续跑列表变大后出现成功数 > 目标 + int total = Math.max(totalAll > 0 ? totalAll : (baseProcessed + pending), baseProcessed + pending); AtomicInteger ok = new AtomicInteger(Math.max(0, baseOk)); AtomicInteger fail = new AtomicInteger(Math.max(0, baseFail)); AtomicInteger skip = new AtomicInteger(Math.max(0, baseSkip)); @@ -1423,7 +1448,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { try { progressSnapshot(logId, Math.min(95, 5 + (int) (processed.get() * 90L / Math.max(1, total))), "PUSH", (baseProcessed > 0 ? "继续向下游「" : "开始向下游「") + partnerCode + "」推送,共 " + total - + " 家,已处理 " + processed.get() + ",并发=" + concurrency + "…", + + " 家,已处理 " + processed.get() + ",并发=" + concurrency + + "(按酒店计:成功+失败+跳过=已处理,目标=计划酒店数)…", ok.get(), fail.get(), skip.get(), total, failLines); if (CollUtil.isEmpty(hotelIds)) { // fall through to complete @@ -1835,9 +1861,47 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { private void clearResumeCkpt(Long logId) { if (logId != null) { redisTemplate.delete(RESUME_CKPT_KEY + logId); + redisTemplate.delete(RESUME_HOTELS_KEY + logId); } } + /** 下游推送开跑时落酒店 ID 快照,供暂停后续跑(尤其 RETRY 失败子集) */ + private void saveResumeHotelIds(Long logId, List hotelIds) { + if (logId == null || CollUtil.isEmpty(hotelIds)) { + return; + } + String joined = hotelIds.stream().filter(Objects::nonNull).map(String::valueOf) + .collect(Collectors.joining(",")); + if (StrUtil.isBlank(joined)) { + return; + } + redisTemplate.opsForValue().set(RESUME_HOTELS_KEY + logId, joined, 14, TimeUnit.DAYS); + } + + private List readResumeHotelIds(Long logId) { + if (logId == null) { + return List.of(); + } + String v = redisTemplate.opsForValue().get(RESUME_HOTELS_KEY + logId); + if (StrUtil.isBlank(v)) { + return List.of(); + } + List ids = new ArrayList<>(); + for (String part : v.split(",")) { + String p = StrUtil.trim(part); + if (StrUtil.isBlank(p)) { + continue; + } + try { + ids.add(Long.valueOf(p)); + } + catch (Exception ignored) { + // skip bad token + } + } + return ids; + } + /** * 全量详情:酒店级并行 + 可缩房价窗,尽量吃满上游 price-api-qps。 * 本地及其它渠道酒店一律不进此任务。 From 44ac3a8b9cfb03e6ff633f6a0cc1653553a1f687 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Thu, 12 Nov 2026 22:20:15 +0800 Subject: [PATCH 07/10] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/SmHotelMidServiceImpl.java | 94 ++++++++++--------- 1 file changed, 48 insertions(+), 46 deletions(-) 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 02ae1950e..e8a8d2b8d 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 @@ -1197,10 +1197,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { Map hotelObj = new LinkedHashMap<>(); hotelObj.put("hotelId", supplierHotelId); hotelObj.put("hotelName", StrUtil.blankToDefault(hotel.getHotelName(), supplierHotelId)); - String cityName = extractHuanqiCityNameFromDistrict(StrUtil.trim(hotel.getDistrict())); - if (StrUtil.isBlank(cityName)) { - cityName = StrUtil.trim(hotel.getDistrict()); - } + String cityName = resolveHuanqiCityName(hotel, null); hotelObj.put("cityName", StrUtil.blankToDefault(cityName, "未知")); hotelObj.put("address", StrUtil.blankToDefault(buildHuanqiFullAddress(hotel.getDistrict(), hotel.getAddress()), "-")); @@ -1593,13 +1590,17 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { hotelBody.put("cityName", hotel.getDistrict()); } parseRemarkAddress(hotel.getRemark(), hotelBody); - // 环启必填:cityName / address / receptionPhone;缺一则本店推送失败 - String cityName = StrUtil.trim(strObj(hotelBody.get("cityName"))); + // cityName:只从位置区域取「xx市」,没有再看其它字段;取不到则不调环启,只记跳过流水 + String cityName = resolveHuanqiCityName(hotel, StrUtil.trim(strObj(hotelBody.get("cityName")))); if (StrUtil.isBlank(cityName)) { - cityName = StrUtil.trim(hotel.getDistrict()); + String reason = "位置区域未解析到市级(需含 xx市),跳过环启推送;district=" + + StrUtil.blankToDefault(hotel.getDistrict(), "空"); + recordDownstreamPushSkip(partnerCode, hotel, reason); + Map skip = new HashMap<>(6); + skip.put("skipped", true); + skip.put("skipReason", reason); + return skip; } - // 环启:只取位置区域里「市级」那一层,再去掉「市」(恩施市→恩施;青岛市城阳区→青岛) - cityName = extractHuanqiCityNameFromDistrict(cityName); String address = StrUtil.trim(strObj(hotelBody.get("address"))); if (StrUtil.isBlank(address)) { address = buildHuanqiFullAddress(hotel.getDistrict(), hotel.getAddress()); @@ -1612,9 +1613,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { if (StrUtil.isBlank(phone)) { phone = StrUtil.trim(hotel.getPhone()); } - if (StrUtil.isBlank(cityName) || StrUtil.isBlank(address) || StrUtil.isBlank(phone)) { - throw new IllegalArgumentException("环启酒店必填缺失:cityName/address/receptionPhone(hotelId=" + hotelId - + ",请补全城市/地址/电话后再推)"); + if (StrUtil.isBlank(address) || StrUtil.isBlank(phone)) { + throw new IllegalArgumentException("环启酒店必填缺失:address/receptionPhone(hotelId=" + hotelId + + ",请补全地址/电话后再推)"); } hotelBody.put("cityName", cityName); hotelBody.put("address", address); @@ -3237,42 +3238,56 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } /** - * 从位置区域文案取「市级」层名称并去掉「市」。 - *

    - * 规则:先按省/市/州/区县拆层;优先取以「市」结尾的那一层(县级市如恩施市也算); - * 若只有区县(如城阳区),则退回上一层地级市(青岛市)。 - *

    + * 环启 cityName: + *
      + *
    1. 优先从「位置区域」district 取带「市」的那一层(天津市津南区 → 天津市)
    2. + *
    3. 没有再从其它字段(如 remark 的 city=、调用方传入值)取
    4. + *
    5. 仍取不到带「市」的名称则返回空,不编造
    6. + *
    + */ + static String resolveHuanqiCityName(SmHotelEntity hotel, String preferredRaw) { + // 1) 位置区域 + String fromDistrict = extractHuanqiCityNameFromDistrict(hotel == null ? null : hotel.getDistrict()); + if (StrUtil.isNotBlank(fromDistrict)) { + return fromDistrict; + } + // 2) 其它字段(remark 解析结果等) + String fromOther = extractHuanqiCityNameFromDistrict(preferredRaw); + if (StrUtil.isNotBlank(fromOther)) { + return fromOther; + } + // 3) 没有市就不用了 + return ""; + } + + /** + * 从文案中只取以「市」结尾的一层;没有则返回空。 *
      - *
    • 湖北省恩施土家族苗族自治州恩施市 → 恩施
    • - *
    • 山东省青岛市城阳区 → 青岛
    • - *
    • 北京市朝阳区 → 北京
    • + *
    • 天津市津南区 → 天津市
    • + *
    • 山东省青岛市城阳区 → 青岛市
    • + *
    • 湖北省恩施土家族苗族自治州恩施市 → 恩施市
    • + *
    • 津南区 / 天津城区 → 空(没有「xx市」就不取)
    • *
    */ static String extractHuanqiCityNameFromDistrict(String district) { if (StrUtil.isBlank(district)) { return ""; } - String s = district.trim().replace(" ", ""); + String s = district.trim().replace(" ", "").replace("/", ""); List segments = splitAreaSegments(s); if (segments.isEmpty()) { - return stripCitySuffix(s); + return s.endsWith("市") && !s.endsWith("市辖区") ? s : ""; } - // 1) 优先:名称以「市」结尾的层(青岛市 / 恩施市 / 北京市) for (int i = segments.size() - 1; i >= 0; i--) { String seg = segments.get(i); + if ("市辖区".equals(seg)) { + continue; + } if (seg.endsWith("市") && !seg.endsWith("市辖区")) { - return stripCitySuffix(seg); + return seg; } } - // 2) 次选:地级「地区/盟」(极少);自治州不作为环启 cityName(对方要的是市短名) - for (int i = segments.size() - 1; i >= 0; i--) { - String seg = segments.get(i); - if (seg.endsWith("地区") || seg.endsWith("盟")) { - return seg.replaceAll("(地区|盟)$", ""); - } - } - // 3) 兜底:最后一层去掉区县后缀 - return stripCitySuffix(segments.get(segments.size() - 1)); + return ""; } /** 湖北省恩施土家族苗族自治州恩施市 → [湖北省, 恩施土家族苗族自治州, 恩施市] */ @@ -3288,19 +3303,6 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { return list; } - /** 去掉末尾「市」(仅去一层后缀,不碰自治州等) */ - static String stripCitySuffix(String name) { - if (StrUtil.isBlank(name)) { - return ""; - } - String n = name.trim(); - if (n.endsWith("市") && !n.endsWith("市辖区") && n.length() > 1) { - return n.substring(0, n.length() - 1); - } - n = n.replaceAll("(区|县|旗)$", ""); - return n.trim(); - } - private static class DailyDraft { private String productCode; private String roomTypeCode; From e658118d8549ebe5d13b33223eb8bb348b152251 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Thu, 12 Nov 2026 22:26:32 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../supermarket/service/impl/SmHotelSyncServiceImpl.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java index 689be1b3e..3f21d8565 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java @@ -45,6 +45,7 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; @@ -53,6 +54,7 @@ import java.util.concurrent.Future; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; /** * 酒店列表日更 + Redis SDIFF + 滚价;手动/定时均写同步日志并支持进度查询。 From cb4ef187f1eaaaf3a7c46cd4c1b7e06728426913 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Fri, 13 Nov 2026 10:58:42 +0800 Subject: [PATCH 09/10] =?UTF-8?q?1.=E9=85=92=E5=BA=97=202.=E9=85=92?= =?UTF-8?q?=E5=BA=97=E6=B6=88=E6=81=AF=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/SmHotelOrderEntity.java | 2 +- .../impl/SmHotelHuidingOrderServiceImpl.java | 69 ++++++++- .../impl/SmHotelMgrPushServiceImpl.java | 53 +++++++ .../service/impl/SmHotelMidServiceImpl.java | 36 ++--- .../impl/SmHotelShengnuoOrderServiceImpl.java | 92 +++++++++++- .../service/impl/SmHotelSyncServiceImpl.java | 42 +++--- .../bsd/pjcloud/thirdparty/ThirdPartyApp.java | 4 +- .../appmessage/AppMessageConstants.java | 4 +- .../appmessage/ThirdAppMessageController.java | 10 +- .../job/ThirdAppMessagePushJob.java | 34 +++++ .../ThirdHotelManagerNotifyService.java | 4 +- .../appmessage/ThirdPushDeliveryService.java | 11 +- .../ThirdHotelManagerNotifyServiceImpl.java | 57 +++++++- .../ThirdPushDeliveryServiceImpl.java | 132 +++++++++++++----- 14 files changed, 449 insertions(+), 101 deletions(-) create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppMessagePushJob.java diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java index 96c9db98e..df09fbc1f 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java @@ -64,7 +64,7 @@ public class SmHotelOrderEntity extends Model { private String guestJson; - @Schema(description = "CREATED/PENDING/UPSTREAM_OK/CHECKED_IN/CANCELLED/UPSTREAM_FAIL/PENDING_CONFIRM/NEED_MANUAL") + @Schema(description = "CREATED/PENDING/UPSTREAM_OK/CHECKED_IN/CANCELLED/UPSTREAM_FAIL/BOOK_FAIL/PENDING_CONFIRM/NEED_MANUAL") private String orderStatus; @Schema(description = "上游 bookStatus:P/R/E/X") 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 106fdaa03..3399f7972 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 @@ -167,12 +167,15 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic .eq(SmHotelOrderEntity::getChannelNo, channelOrderNo) .last("limit 1")); if (exist != null) { - outer.put("msg", "订单已存在(幂等)"); - outer.put("data", buildCreateData(exist)); - return outer; + if (!"BOOK_FAIL".equals(exist.getOrderStatus())) { + outer.put("msg", "订单已存在(幂等)"); + outer.put("data", buildCreateData(exist)); + return outer; + } } MappedOrder mapped = mapCreateFields(partnerCode, data); if (mapped.error != null) { + saveBookFailOrder(exist, partnerCode, channelOrderNo, mapped, data, mapped.error); return fail200(mapped.error); } Map checkBody = new LinkedHashMap<>(); @@ -191,13 +194,16 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic && Boolean.TRUE.equals(check.getData().get("bookable")); if (!bookable) { Object reasons = check == null || check.getData() == null ? "预检失败" : check.getData().get("reasons"); - return fail200("预检不可订:" + reasons); + String failMsg = "预检不可订:" + reasons; + saveBookFailOrder(exist, partnerCode, channelOrderNo, mapped, data, failMsg); + return fail200(failMsg); } - SmHotelOrderEntity order = new SmHotelOrderEntity(); + SmHotelOrderEntity order = exist != null ? exist : new SmHotelOrderEntity(); order.setPartnerCode(partnerCode); order.setChannelNo(channelOrderNo); order.setHotelId(mapped.hotel.getId()); + order.setDeptId(mapped.hotel.getDeptId()); order.setHotelCode(mapped.hotelCode); order.setGroupCode(mapped.hotel.getGroupCode()); order.setRoomTypeCode(mapped.roomTypeCode); @@ -215,9 +221,15 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic order.setCompensateStatus("NONE"); order.setInventoryStatus("NONE"); order.setCompensateRound(0); + order.setFailMsg(null); order.setReqJson(data.toString()); order.setHuanqiPayload(data.toString()); - orderMapper.insert(order); + if (order.getId() == null) { + orderMapper.insert(order); + } + else { + orderMapper.updateById(order); + } // 本地酒店:占库存后 PENDING_CONFIRM,返回待确认,人工确认后再 operationOrder=1 // 上游酒店:同步订上游,成功当场确认 @@ -480,11 +492,56 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic } return StrUtil.isNotBlank(order.getPlatformNo()) && !"UPSTREAM_FAIL".equals(order.getOrderStatus()) + && !"BOOK_FAIL".equals(order.getOrderStatus()) && !"NEED_MANUAL".equals(order.getOrderStatus()) && !"CANCELLED".equals(order.getOrderStatus()) && !"PENDING_CANCEL".equals(order.getOrderStatus()); } + /** 入站预检/映射失败落 BOOK_FAIL,对慧订仍返回失败 */ + private void saveBookFailOrder(SmHotelOrderEntity exist, String partnerCode, String channelOrderNo, + MappedOrder mapped, JSONObject data, String failMsg) { + try { + SmHotelOrderEntity order = exist != null ? exist : new SmHotelOrderEntity(); + order.setPartnerCode(partnerCode); + order.setChannelNo(channelOrderNo); + order.setOrderStatus("BOOK_FAIL"); + order.setCompensateStatus("NONE"); + order.setInventoryStatus("NONE"); + order.setCompensateRound(0); + order.setMarkupPercent(BigDecimal.ZERO); + order.setFailMsg(StrUtil.maxLength(failMsg, 500)); + order.setReqJson(data == null ? null : data.toString()); + order.setHuanqiPayload(data == null ? null : data.toString()); + if (mapped != null && mapped.hotel != null) { + order.setHotelId(mapped.hotel.getId()); + order.setDeptId(mapped.hotel.getDeptId()); + order.setHotelCode(mapped.hotelCode); + order.setGroupCode(mapped.hotel.getGroupCode()); + order.setRoomTypeCode(mapped.roomTypeCode); + order.setProductCode(mapped.productCode); + order.setPriceCode(mapped.product == null ? null : mapped.product.getPriceCode()); + order.setCheckInDate(mapped.checkIn); + order.setCheckOutDate(mapped.checkOut); + order.setCount(mapped.quantity <= 0 ? 1 : mapped.quantity); + order.setContactName(mapped.contactName); + order.setContactPhone(mapped.contactPhone); + order.setGuestJson(mapped.guestJson); + order.setSellAmount(mapped.orderAmount); + } + if (order.getId() == null) { + orderMapper.insert(order); + } + else { + orderMapper.updateById(order); + } + log.info("|慧订订单|预订失败已落单|channelNo={}|localId={}|msg={}|", channelOrderNo, order.getId(), failMsg); + } + catch (Exception e) { + log.warn("|慧订订单|预订失败落单异常|channelNo={}|{}|", channelOrderNo, e.getMessage()); + } + } + private static boolean isPendingConfirm(SmHotelOrderEntity order) { return order != null && "PENDING_CONFIRM".equals(order.getOrderStatus()); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMgrPushServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMgrPushServiceImpl.java index 1cf73a1c3..ca9202ced 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMgrPushServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMgrPushServiceImpl.java @@ -21,6 +21,7 @@ import com.bsd.pjcloud.supermarket.mapper.SmHotelMgrPushConfigMapper; import com.bsd.pjcloud.supermarket.mapper.SmHotelMgrPushLogMapper; import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderMapper; import com.bsd.pjcloud.supermarket.service.SmHotelMgrPushService; +import com.bsd.pjcloud.thirdparty.api.feign.RemoteThirdPushDeliveryService; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppMessageService; import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelManagerPushEventDto; import lombok.RequiredArgsConstructor; @@ -79,6 +80,9 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { private final RemoterThirdAppMessageService remoteAppMessageService; + /** 推送入箱后立刻踢一轮投递扫描,减少对 Quartz 的依赖 */ + private final RemoteThirdPushDeliveryService remotePushDeliveryService; + @Override @Transactional(rollbackFor = Exception.class) public SmHotelMgrPushConfigEntity getOrCreateConfig() { @@ -181,11 +185,23 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { if (orderId == null) { return PushResult.fail("订单为空"); } + // 同一 JVM 内串行化同订单自动推送,避免补偿/双路径并发各写一条 6/6 + synchronized (("HOTEL_MGR_PUSH_ORDER:" + orderId).intern()) { + return doPushNewOrderLocked(orderId, force); + } + } + + private PushResult doPushNewOrderLocked(Long orderId, boolean force) { try { SmHotelMgrPushConfigEntity cfg = getOrCreateConfig(); if (!force && !ENABLED.equals(cfg.getNewOrderEnabled())) { return PushResult.skip("新订单推送已关闭"); } + // 自动推送:同一订单只真正推一次;补偿/补上游会反复触发 schedule,但手机端 eventId 幂等本就只会响一次 + if (!force && alreadyAutoPushedNewOrder(orderId)) { + log.info("|酒店管理推送|新订单已推送过,跳过|orderId={}|", orderId); + return PushResult.skip("该订单已自动推送过"); + } SmHotelOrderEntity order = orderMapper.selectById(orderId); if (order == null) { return PushResult.fail("订单不存在"); @@ -315,7 +331,11 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { requestJson); return force ? PushResult.fail(tip) : PushResult.skip(tip); } + kickPushDeliveryScan(); String msg = "已推送成功 " + ok + "/" + recipientList.size() + " 人" + detail; + if (fail > 0 && !failHints.isEmpty()) { + msg = msg + ";部分失败:" + String.join(";", failHints); + } savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId), order.getHotelId(), hotelName, logDepts, recipientList, ok, fail, title, content, msg, requestJson); @@ -426,7 +446,11 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { logDepts, leaderList, 0, fail, title, content, tip, requestJson); return force ? PushResult.fail(tip) : PushResult.skip(tip); } + kickPushDeliveryScan(); String msg = "日历提醒成功 " + ok + "/" + leaders.size() + " 人(负责人=" + leaderNames + ")"; + if (fail > 0 && !failHints.isEmpty()) { + msg = msg + ";部分失败:" + String.join(";", failHints); + } savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null, logDepts, leaderList, ok, fail, title, content, msg, requestJson); return PushResult.success(ok, leaders.size(), msg); @@ -514,6 +538,20 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { return item; } + /** 是否已有该订单的自动新订单成功/部分成功记录(补偿重试不应再刷手机与推送日志) */ + private boolean alreadyAutoPushedNewOrder(Long orderId) { + if (orderId == null) { + return false; + } + Long cnt = pushLogMapper.selectCount(Wrappers.lambdaQuery() + .eq(SmHotelMgrPushLogEntity::getPushType, SmHotelMgrPushLogEntity.TYPE_NEW_ORDER) + .eq(SmHotelMgrPushLogEntity::getTriggerType, SmHotelMgrPushLogEntity.TRIGGER_AUTO) + .eq(SmHotelMgrPushLogEntity::getBizId, String.valueOf(orderId)) + .in(SmHotelMgrPushLogEntity::getStatus, SmHotelMgrPushLogEntity.STATUS_OK, + SmHotelMgrPushLogEntity.STATUS_PARTIAL)); + return cnt != null && cnt > 0; + } + private void savePushLog(String pushType, String triggerType, String bizId, Long hotelId, String hotelName, List deptIds, List userIds, int success, int fail, String title, String content, String resultMsg, String requestJson) { @@ -633,6 +671,21 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService { } } + /** 入箱后立刻扫一轮极光投递,避免只靠 Quartz(任务停了会一直不响) */ + private void kickPushDeliveryScan() { + if (remotePushDeliveryService == null) { + return; + } + try { + R resp = remotePushDeliveryService.processDueDeliveries(); + log.info("|酒店管理推送|踢投递扫描|claimed={}|ok={}|", + resp == null ? null : resp.getData(), resp != null && resp.isOk()); + } + catch (Exception e) { + log.warn("|酒店管理推送|踢投递扫描失败|{}", e.getMessage()); + } + } + /** * 本部门负责人(不含下级):upms listDeptLeader,岗位编码 TEAM_LEADER。 * Feign 反序列化可能把 id 解成 Integer,这里统一转 Long。 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 e8a8d2b8d..520bfd364 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 @@ -252,7 +252,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { hotelPullTx.executeWithoutResult(status -> { hotelMapper.updateById(hotel); counts[0] = upsertRoomsFromComplex(hotel, complex); - ensureRoomsBatch(hotel, roomTypesFromPrice); + ensureRoomsBatch(hotel, roomTypesFromPrice); counts[1] = persistProducts(hotel, productMap); counts[2] = persistDailies(hotel, dailyDrafts, start, endExclusive); }); @@ -2916,8 +2916,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } } } - return 0; - } + return 0; + } private int persistDailiesOnce(SmHotelEntity hotel, List drafts, LocalDate fromInclusive, LocalDate toExclusive) { @@ -2969,15 +2969,15 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { Map unique = new LinkedHashMap<>(); if (CollUtil.isNotEmpty(drafts)) { - for (DailyDraft d : drafts) { + for (DailyDraft d : drafts) { if (d == null || d.stayDate == null || StrUtil.isBlank(d.productCode)) { continue; } if (d.stayDate.isBefore(winFrom) || !d.stayDate.isBefore(winToEx)) { continue; } - unique.put(d.productCode + "|" + d.stayDate, d); - } + unique.put(d.productCode + "|" + d.stayDate, d); + } } List existRows = dailyMapper.selectList(Wrappers.lambdaQuery() @@ -3005,19 +3005,19 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { String key = d.productCode + "|" + d.stayDate; SmHotelDailyEntity exist = existMap.remove(key); if (exist == null) { - SmHotelDailyEntity daily = new SmHotelDailyEntity(); - daily.setHotelId(hotel.getId()); - daily.setProductId(productId); - daily.setRoomTypeCode(d.roomTypeCode); - daily.setProductCode(d.productCode); - daily.setStayDate(d.stayDate); - daily.setCostPrice(d.costPrice); - daily.setSellPrice(d.sellPrice); - daily.setStock(d.stock); + SmHotelDailyEntity daily = new SmHotelDailyEntity(); + daily.setHotelId(hotel.getId()); + daily.setProductId(productId); + daily.setRoomTypeCode(d.roomTypeCode); + daily.setProductCode(d.productCode); + daily.setStayDate(d.stayDate); + daily.setCostPrice(d.costPrice); + daily.setSellPrice(d.sellPrice); + daily.setStock(d.stock); daily.setSold(0); - daily.setDailyStatus(d.dailyStatus); - fillNewMeta(daily, tenantId, now); - toInsert.add(daily); + daily.setDailyStatus(d.dailyStatus); + fillNewMeta(daily, tenantId, now); + toInsert.add(daily); continue; } // 有则改:保留 sold,避免冲掉本地占房 diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java index 80c6199b9..48645dad2 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java @@ -112,11 +112,16 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ if (exist != null) { if (isBookConfirmed(exist)) { pushOrderChange(partnerCode, orderId, ST_ACCEPTED); + return ok("订单已存在(幂等)", exist.getId()); + } + // 预订失败单允许同渠道单号重新预检;其它中间态仍幂等 + if (!"BOOK_FAIL".equals(exist.getOrderStatus())) { + return ok("订单已存在(幂等)", exist.getId()); } - return ok("订单已存在(幂等)", exist.getId()); } Mapped m = mapCreate(partnerCode, body); if (m.error != null) { + saveBookFailOrder(exist, partnerCode, orderId, m, body, m.error); return fail(m.error); } Map checkBody = new LinkedHashMap<>(); @@ -137,7 +142,9 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ boolean priceSoft = checkData != null && Boolean.TRUE.equals(checkData.get("priceMismatchOnly")); if (!bookable && !priceSoft) { Object reasons = checkData == null ? "预检失败" : checkData.get("reasons"); - return fail("预检不可订:" + reasons); + String failMsg = "预检不可订:" + reasons; + saveBookFailOrder(exist, partnerCode, orderId, m, body, failMsg); + return fail(failMsg); } String priceWarn = null; if (priceSoft) { @@ -147,10 +154,11 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ log.warn("|{}|价差软通过|orderId={}|{}|", TAG, orderId, priceWarn); } - SmHotelOrderEntity order = new SmHotelOrderEntity(); + SmHotelOrderEntity order = exist != null ? exist : new SmHotelOrderEntity(); order.setPartnerCode(partnerCode); order.setChannelNo(orderId); order.setHotelId(m.hotel.getId()); + order.setDeptId(m.hotel.getDeptId()); order.setHotelCode(m.hotelCode); order.setGroupCode(m.hotel.getGroupCode()); order.setRoomTypeCode(m.roomTypeCode); @@ -168,12 +176,18 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ order.setCompensateStatus("NONE"); order.setInventoryStatus("NONE"); order.setCompensateRound(0); + order.setFailMsg(null); order.setReqJson(body.toString()); order.setHuanqiPayload(body.toString()); if (priceWarn != null) { order.setFailMsg(StrUtil.maxLength(priceWarn, 500)); } - orderMapper.insert(order); + if (order.getId() == null) { + orderMapper.insert(order); + } + else { + orderMapper.updateById(order); + } R book = hotelOrderService.bookUpstreamForExisting(order.getId()); SmHotelOrderEntity latest = orderMapper.selectById(order.getId()); @@ -300,6 +314,9 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ m.error = "未找到本地酒店(请先推送资源建立映射,或核对外部酒店ID/sHotelId)nsHotelId=" + nsHotelId; return m; } + // 后续匹配失败时仍带上酒店,便于预订失败单挂到对应门店 + m.hotel = hotel; + m.hotelCode = hotel.getHotelCode(); String roomType = idMappingCache.roomTypeByNs(map, nsRoomId); if (StrUtil.isBlank(roomType)) { roomType = matchLocalRoomType(hotel.getId(), nsRoomId, StrUtil.trim(str(body.get("room_type"))), map); @@ -600,11 +617,78 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ } return StrUtil.isNotBlank(order.getPlatformNo()) && !"UPSTREAM_FAIL".equals(order.getOrderStatus()) + && !"BOOK_FAIL".equals(order.getOrderStatus()) && !"NEED_MANUAL".equals(order.getOrderStatus()) && !"CANCELLED".equals(order.getOrderStatus()) && !"PENDING_CANCEL".equals(order.getOrderStatus()); } + /** + * 入站预检/映射失败也落酒店订单(BOOK_FAIL),便于运营在订单列表实时看到进单与原因;对下游仍返回失败。 + */ + private Long saveBookFailOrder(SmHotelOrderEntity exist, String partnerCode, String orderId, Mapped m, + JSONObject body, String failMsg) { + try { + SmHotelOrderEntity order = exist != null ? exist : new SmHotelOrderEntity(); + order.setPartnerCode(partnerCode); + order.setChannelNo(orderId); + order.setOrderStatus("BOOK_FAIL"); + order.setCompensateStatus("NONE"); + order.setInventoryStatus("NONE"); + order.setCompensateRound(0); + order.setMarkupPercent(BigDecimal.ZERO); + order.setFailMsg(StrUtil.maxLength(failMsg, 500)); + order.setReqJson(body == null ? null : body.toString()); + order.setHuanqiPayload(body == null ? null : body.toString()); + if (m != null && m.hotel != null) { + order.setHotelId(m.hotel.getId()); + order.setDeptId(m.hotel.getDeptId()); + order.setHotelCode(m.hotelCode); + order.setGroupCode(m.hotel.getGroupCode()); + order.setRoomTypeCode(m.roomTypeCode); + order.setProductCode(m.productCode); + order.setPriceCode(m.product == null ? null : m.product.getPriceCode()); + order.setCheckInDate(m.checkIn); + order.setCheckOutDate(m.checkOut); + order.setCount(m.quantity <= 0 ? 1 : m.quantity); + order.setContactName(m.contactName); + order.setContactPhone(m.contactPhone); + order.setGuestJson(m.guestJson); + order.setSellAmount(m.orderAmount); + } + else if (body != null) { + long nsHotelId = toLong(body.get("hotel_id")); + if (nsHotelId > 0) { + order.setHotelCode(String.valueOf(nsHotelId)); + } + order.setCheckInDate(parseDay(str(body.get("checkIn")))); + order.setCheckOutDate(parseDay(str(body.get("checkOut")))); + order.setCount(Math.max(1, (int) toLong(body.get("room_number")))); + order.setContactName(StrUtil.blankToDefault(StrUtil.trim(str(body.get("check_person_name"))), "旅客")); + order.setContactPhone(StrUtil.trim(str(body.get("check_person_phone")))); + String priceStr = StrUtil.blankToDefault(str(body.get("total_prices")), str(body.get("pay_total_prices"))); + try { + order.setSellAmount(new BigDecimal(StrUtil.blankToDefault(priceStr, "0"))); + } + catch (Exception ignored) { + order.setSellAmount(BigDecimal.ZERO); + } + } + if (order.getId() == null) { + orderMapper.insert(order); + } + else { + orderMapper.updateById(order); + } + log.info("|{}|预订失败已落单|orderId={}|localId={}|msg={}|", TAG, orderId, order.getId(), failMsg); + return order.getId(); + } + catch (Exception e) { + log.warn("|{}|预订失败落单异常|orderId={}|{}|", TAG, orderId, e.getMessage()); + return exist == null ? null : exist.getId(); + } + } + private static boolean isPendingConfirm(SmHotelOrderEntity order) { return order != null && "PENDING_CONFIRM".equals(order.getOrderStatus()); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java index 3f21d8565..9786f14ba 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java @@ -564,10 +564,10 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { } } else { - Long running = syncLogMapper.selectCount(Wrappers.lambdaQuery() - .eq(SmHotelSyncLogEntity::getSyncType, syncType) - .eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING)); - if (running != null && running > 0) { + Long running = syncLogMapper.selectCount(Wrappers.lambdaQuery() + .eq(SmHotelSyncLogEntity::getSyncType, syncType) + .eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING)); + if (running != null && running > 0) { return R.failed("已有同类型任务进行中,请稍后再试(若卡住请先暂停/终止)"); } } @@ -1160,7 +1160,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { + remain + "(每批 " + batchSize + ")"); R> imp = importDiffNewHotels(batchSize, dataSource); int n = 0; - if (imp != null && imp.getData() != null && imp.getData().get("insertCount") != null) { + if (imp != null && imp.getData() != null && imp.getData().get("insertCount") != null) { n = Integer.parseInt(String.valueOf(imp.getData().get("insertCount"))); } if (n <= 0) { @@ -2028,12 +2028,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { processed.incrementAndGet(); } int doneCnt = processed.get(); - if (intervalMs > 0) { - try { - Thread.sleep(intervalMs); - } - catch (InterruptedException ie) { - Thread.currentThread().interrupt(); + if (intervalMs > 0) { + try { + Thread.sleep(intervalMs); + } + catch (InterruptedException ie) { + Thread.currentThread().interrupt(); } } if (doneCnt % 20 == 0 || doneCnt >= target) { @@ -2882,10 +2882,10 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { continue; } codes.add(code); - if (codes.size() >= batch) { - updated += updateStatusByCodes(codes, "OFFLINE"); - codes.clear(); - } + if (codes.size() >= batch) { + updated += updateStatusByCodes(codes, "OFFLINE"); + codes.clear(); + } } if (!codes.isEmpty()) { updated += updateStatusByCodes(codes, "OFFLINE"); @@ -2968,12 +2968,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { int to = (int) Math.min(skip + size, ordered.size()); for (int i = from; i < to; i++) { String hotelId = ordered.get(i); - Map row = new LinkedHashMap<>(4); - row.put("hotelId", hotelId); - row.put("hotelName", upstreamClient.hotelNameOf(hotelId)); - row.put("type", "offline".equalsIgnoreCase(type) ? "offline" : "new"); - records.add(row); - } + Map row = new LinkedHashMap<>(4); + row.put("hotelId", hotelId); + row.put("hotelName", upstreamClient.hotelNameOf(hotelId)); + row.put("type", "offline".equalsIgnoreCase(type) ? "offline" : "new"); + records.add(row); + } } } Map result = new LinkedHashMap<>(); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/ThirdPartyApp.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/ThirdPartyApp.java index 825b3ea35..85c7c7fbd 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/ThirdPartyApp.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/ThirdPartyApp.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.swagger.annotation.EnableOpenApi; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.scheduling.annotation.EnableScheduling; /** * @author archetype auto @@ -16,7 +17,8 @@ import org.springframework.cloud.client.discovery.EnableDiscoveryClient; @EnablePjcloudFeignClients @EnableDiscoveryClient @EnablePjcloudResourceServer -// 周期调度已统一迁移到 daemon-quartz(sys_job 注册 thirdPushTask / 支付任务租约表),集群下避免各实例重复触发 +// daemon-quartz thirdPushTask 仍为主调度;本地 @Scheduled 仅作系统通知 5 秒兜底(CAS 领取,可与 Quartz 并存) +@EnableScheduling @SpringBootApplication public class ThirdPartyApp { public static void main(String[] args) { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java index d79cdf4d9..edd931d6a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java @@ -109,8 +109,8 @@ public class AppMessageConstants { /** 单个投递任务允许的最大供应商调用次数。 */ public static final int MAX_DELIVERY_ATTEMPTS = 8; - /** 处理中超过该分钟数视为任务失联并允许恢复。 */ - public static final int PROCESSING_TIMEOUT_MINUTES = 10; + /** 处理中超过该分钟数视为任务失联并允许恢复(原 10 分钟过长,系统通知会像「卡死」)。 */ + public static final int PROCESSING_TIMEOUT_MINUTES = 2; /** 一次投递扫描领取的最大任务数,控制数据库和供应商压力。 */ public static final int DELIVERY_SCAN_LIMIT = 100; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java index 962f01bcd..fad2695b1 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java @@ -1,5 +1,6 @@ package com.bsd.pjcloud.thirdparty.controller.appmessage; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import com.bsd.pjcloud.common.core.util.R; @@ -98,14 +99,19 @@ public class ThirdAppMessageController { /** * 内部幂等接收酒店管理推送(不进入商户策略/关系订阅矩阵;独立于有房通知)。 + *

    收件箱写入成功但系统通知被跳过(无设备/用户关推送)时返回失败文案,避免上游误记为「手机已送达」。 * * @param dto 超市酒店管理推送事件快照 - * @return true表示消息中心已完整接收 + * @return true 表示入箱且系统通知已进入投递;失败时 msg 说明跳过原因 */ @Inner @PostMapping("/inner/hotel-manager-push-event") public R receiveHotelManagerPushEvent(@Valid @RequestBody HotelManagerPushEventDto dto) { - return R.ok(TenantBroker.applyAs(dto.getTenantId(), tenantId -> hotelManagerNotifyService.receive(dto))); + String skipTip = TenantBroker.applyAs(dto.getTenantId(), tenantId -> hotelManagerNotifyService.receive(dto)); + if (StrUtil.isNotBlank(skipTip)) { + return R.failed(skipTip); + } + return R.ok(Boolean.TRUE); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppMessagePushJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppMessagePushJob.java new file mode 100644 index 000000000..614be0c6e --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppMessagePushJob.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.thirdparty.job; + +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * App 消息系统通知本地兜底扫描。 + *

    正式调度仍以 daemon-quartz thirdPushTask 为准;本地短周期扫描保证 Quartz 停摆/积压时 + * 系统通知不会拖到数分钟。领取靠 status CAS,多实例重复触发安全。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ThirdAppMessagePushJob { + + private final ThirdPushDeliveryService deliveryService; + + /** 上轮结束后再隔 5 秒扫一轮 PENDING/RETRY */ + @Scheduled(fixedDelay = 5000L, initialDelay = 8000L) + public void processDueDeliveries() { + try { + int claimed = deliveryService.processDueDeliveries(); + if (claimed > 0) { + log.info("|App消息Push|本地兜底扫描完成|claimed={}|", claimed); + } + } + catch (Exception e) { + log.error("|App消息Push|本地兜底扫描异常|{}", e.getMessage(), e); + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdHotelManagerNotifyService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdHotelManagerNotifyService.java index 14b2bcd57..f9ee47dc8 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdHotelManagerNotifyService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdHotelManagerNotifyService.java @@ -11,7 +11,7 @@ public interface ThirdHotelManagerNotifyService { * 幂等接收酒店管理推送事件。 * * @param dto 超市模块提交的事件快照 - * @return true 表示已入箱或此前已幂等完成 + * @return null 表示入箱且系统通知已进入待投递/已幂等;非空为跳过系统通知的中文原因(收件箱通常已写入) */ - boolean receive(HotelManagerPushEventDto dto); + String receive(HotelManagerPushEventDto dto); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java index 85690c424..334951589 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java @@ -18,8 +18,9 @@ public interface ThirdPushDeliveryService { * * @param message 已持久化的收件箱消息 * @param skipReason 稳定跳过原因,允许为空 + * @return 最终跳过原因;null 表示已进入待投递(或已有投递记录) */ - void createDelivery(ThirdAppMessageEntity message, String skipReason); + String createDelivery(ThirdAppMessageEntity message, String skipReason); /** * 扫描并处理所有租户到期的投递任务,供轻量定时任务调用。 @@ -28,6 +29,14 @@ public interface ThirdPushDeliveryService { */ int processDueDeliveries(); + /** + * 事务提交后立即尝试投递指定收件箱消息(PENDING),不坐等 Quartz 扫描。 + * + * @param messageId 收件箱消息ID + * @return 是否成功领取并处理 + */ + boolean processPendingByMessageId(Long messageId); + /** * 分页查询指定商户的业务消息投递记录。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdHotelManagerNotifyServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdHotelManagerNotifyServiceImpl.java index d9558a125..a848f40ed 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdHotelManagerNotifyServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdHotelManagerNotifyServiceImpl.java @@ -10,15 +10,20 @@ import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingServi import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdHotelManagerNotifyService; import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.Set; +import java.util.concurrent.CompletableFuture; /** * 酒店管理推送:仿有房通知旁路,独立 eventCode,避免进入商户策略矩阵。 */ +@Slf4j @Service @RequiredArgsConstructor public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNotifyService { @@ -35,7 +40,7 @@ public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNoti @Override @Transactional(rollbackFor = Exception.class) - public boolean receive(HotelManagerPushEventDto dto) { + public String receive(HotelManagerPushEventDto dto) { if (dto == null || StrUtil.isBlank(dto.getEventId()) || dto.getRecipientUserId() == null) { throw new IllegalArgumentException("酒店管理推送参数不完整"); } @@ -44,7 +49,7 @@ public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNoti throw new IllegalArgumentException("不支持的酒店管理推送事件编码: " + eventCode); } if (ownerMessageExists(dto.getEventId(), dto.getRecipientUserId())) { - return true; + return null; } String title = trimToMax(dto.getTitle(), AppMessageConstants.MESSAGE_TITLE_MAX_LENGTH); String content = trimToMax(dto.getContent(), AppMessageConstants.MESSAGE_CONTENT_MAX_LENGTH); @@ -74,13 +79,45 @@ public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNoti } } catch (DuplicateKeyException conflict) { - return true; + return null; } String skipReason = settingService.isUserPushEnabled(dto.getRecipientUserId()) ? null : AppMessageConstants.SKIP_USER_DISABLED; - deliveryService.createDelivery(message, skipReason); - return true; + String resolvedSkip = deliveryService.createDelivery(message, skipReason); + if (resolvedSkip != null) { + log.warn("|酒店管理推送|系统通知跳过|userId={}|eventId={}|reason={}|", + dto.getRecipientUserId(), dto.getEventId(), resolvedSkip); + return explainSkip(resolvedSkip); + } + scheduleImmediateDelivery(message.getId()); + return null; + } + + /** 事务提交后异步立刻投递,避免 PENDING 未可见,也不阻塞 Feign 返回 */ + private void scheduleImmediateDelivery(Long messageId) { + if (messageId == null) { + return; + } + Runnable task = () -> CompletableFuture.runAsync(() -> { + try { + boolean ok = deliveryService.processPendingByMessageId(messageId); + log.info("|酒店管理推送|立即投递|messageId={}|claimed={}|", messageId, ok); + } + catch (Exception e) { + log.warn("|酒店管理推送|立即投递异常|messageId={}|{}", messageId, e.getMessage()); + } + }); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + return; + } + task.run(); } private boolean ownerMessageExists(String eventId, Long userId) { @@ -90,6 +127,16 @@ public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNoti return count != null && count > 0; } + private static String explainSkip(String code) { + if (AppMessageConstants.SKIP_NO_DEVICE.equals(code)) { + return "收件箱已写入,但该用户未绑定推送设备,无法发系统通知(请用手机打开 App 并登录以绑定极光设备)"; + } + if (AppMessageConstants.SKIP_USER_DISABLED.equals(code)) { + return "收件箱已写入,但该用户关闭了消息推送开关"; + } + return "收件箱已写入,系统通知跳过:" + code; + } + private static String trimToMax(String raw, int max) { String text = StrUtil.nullToEmpty(raw).trim(); if (text.length() <= max) { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java index 628defba2..778d35896 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java @@ -96,12 +96,15 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService { * * @param message 已持久化的收件箱消息 * @param skipReason 业务策略跳过原因,允许为空 + * @return 最终跳过原因;null 表示待投递或已有投递记录 */ @Override - public void createDelivery(ThirdAppMessageEntity message, String skipReason) { - if (deliveryMapper.selectCount(Wrappers.lambdaQuery() - .eq(ThirdPushDeliveryEntity::getMessageId, message.getId())) > 0) { - return; + public String createDelivery(ThirdAppMessageEntity message, String skipReason) { + ThirdPushDeliveryEntity existing = deliveryMapper.selectOne(Wrappers.lambdaQuery() + .eq(ThirdPushDeliveryEntity::getMessageId, message.getId()) + .last("limit 1")); + if (existing != null) { + return existing.getSkipReason(); } String resolvedSkipReason = skipReason; if (resolvedSkipReason == null && !deviceBindingService.hasActiveBinding(message.getRecipientUserId())) { @@ -118,6 +121,33 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService { delivery.setSkipReason(resolvedSkipReason); delivery.setAttemptCount(0); deliveryMapper.insert(delivery); + return resolvedSkipReason; + } + + /** + * 立即处理指定消息的 PENDING 投递(事务提交后调用)。 + */ + @Override + public boolean processPendingByMessageId(Long messageId) { + if (messageId == null) { + return false; + } + ThirdPushDeliveryEntity candidate = deliveryMapper.selectOne(Wrappers.lambdaQuery() + .eq(ThirdPushDeliveryEntity::getMessageId, messageId) + .eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PENDING) + .last("limit 1")); + if (candidate == null || candidate.getTenantId() == null) { + return false; + } + try { + Boolean claimed = TenantBroker.applyAs(candidate.getTenantId(), + tenantId -> processOne(candidate.getId())); + return Boolean.TRUE.equals(claimed); + } + catch (Exception exception) { + log.error("立即投递失败,messageId:{},deliveryId:{}", messageId, candidate.getId(), exception); + return false; + } } /** @@ -146,8 +176,9 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService { continue; } try { - Boolean claimed = TenantBroker.applyAs(candidate.getTenantId(), tenantId -> - transactionTemplate.execute(status -> processOne(candidate.getId()))); + // 极光调用不能包在长事务里:否则领取后进程抖动会卡在 PROCESSING 直到超时(原先 10 分钟) + Boolean claimed = TenantBroker.applyAs(candidate.getTenantId(), + tenantId -> processOne(candidate.getId())); if (Boolean.TRUE.equals(claimed)) { claimedCount++; } @@ -361,35 +392,20 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService { } /** - * 领取并处理单条投递任务,条件更新保证多实例只有一个节点获得处理权。 + * 领取并处理单条投递任务:短事务领取 → 事务外调极光 → 短事务落终态。 + * 条件更新保证多实例只有一个节点获得处理权。 * * @param deliveryId 投递任务ID * @return 是否成功领取任务 */ private boolean processOne(Long deliveryId) { - ThirdPushDeliveryEntity current = deliveryMapper.selectById(deliveryId); - if (current == null || current.getAttemptCount() >= AppMessageConstants.MAX_DELIVERY_ATTEMPTS) { - return false; - } - ThirdPushDeliveryEntity claim = new ThirdPushDeliveryEntity(); - claim.setStatus(AppMessageConstants.DELIVERY_PROCESSING); - claim.setNextRetryTime(null); - claim.setUpdateTime(LocalDateTime.now()); - var claimWrapper = Wrappers.lambdaUpdate() - .eq(ThirdPushDeliveryEntity::getId, deliveryId) - .eq(ThirdPushDeliveryEntity::getStatus, current.getStatus()); - if (AppMessageConstants.DELIVERY_PROCESSING.equals(current.getStatus())) { - // 超时任务仍保持PROCESSING状态,必须附加旧更新时间条件,防止多个实例同时把同一任务领取成功。 - claimWrapper.le(ThirdPushDeliveryEntity::getUpdateTime, - LocalDateTime.now().minusMinutes(AppMessageConstants.PROCESSING_TIMEOUT_MINUTES)); - } - int claimed = deliveryMapper.update(claim, claimWrapper); - if (claimed == 0) { + ThirdPushDeliveryEntity current = transactionTemplate.execute(status -> tryClaim(deliveryId)); + if (current == null) { return false; } ThirdAppMessageEntity message = messageMapper.selectById(current.getMessageId()); if (message == null) { - markTerminalFailure(current, "MESSAGE_NOT_FOUND", 1); + transactionTemplate.executeWithoutResult(status -> markTerminalFailure(current, "MESSAGE_NOT_FOUND", 1)); return true; } PushProviderRequest request = new PushProviderRequest(); @@ -400,7 +416,43 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService { "messageId", String.valueOf(message.getId()), "path", AppMessageConstants.MESSAGE_DETAIL_PATH)); int remainingAttempts = AppMessageConstants.MAX_DELIVERY_ATTEMPTS - current.getAttemptCount(); - PushDispatchResult dispatchResult = providerDispatcher.dispatch(request, remainingAttempts); + PushDispatchResult dispatchResult; + try { + dispatchResult = providerDispatcher.dispatch(request, remainingAttempts); + } + catch (Exception exception) { + log.error("Push供应商调用异常,deliveryId:{}", deliveryId, exception); + transactionTemplate.executeWithoutResult(status -> applyDispatchFailure(current, null, + StrUtil.sub(exception.getMessage(), 0, 500), 1)); + return true; + } + transactionTemplate.executeWithoutResult(status -> applyDispatchResult(current, dispatchResult)); + return true; + } + + /** @return 领取成功时的投递快照;未领到返回 null */ + private ThirdPushDeliveryEntity tryClaim(Long deliveryId) { + ThirdPushDeliveryEntity current = deliveryMapper.selectById(deliveryId); + if (current == null || current.getAttemptCount() >= AppMessageConstants.MAX_DELIVERY_ATTEMPTS) { + return null; + } + ThirdPushDeliveryEntity claim = new ThirdPushDeliveryEntity(); + claim.setStatus(AppMessageConstants.DELIVERY_PROCESSING); + claim.setNextRetryTime(null); + claim.setUpdateTime(LocalDateTime.now()); + var claimWrapper = Wrappers.lambdaUpdate() + .eq(ThirdPushDeliveryEntity::getId, deliveryId) + .eq(ThirdPushDeliveryEntity::getStatus, current.getStatus()); + if (AppMessageConstants.DELIVERY_PROCESSING.equals(current.getStatus())) { + // 超时任务仍保持 PROCESSING,必须附加旧更新时间条件,防止多实例同时领取。 + claimWrapper.le(ThirdPushDeliveryEntity::getUpdateTime, + LocalDateTime.now().minusMinutes(AppMessageConstants.PROCESSING_TIMEOUT_MINUTES)); + } + int claimed = deliveryMapper.update(claim, claimWrapper); + return claimed == 0 ? null : current; + } + + private void applyDispatchResult(ThirdPushDeliveryEntity current, PushDispatchResult dispatchResult) { int consumedAttempts = persistAttempts(current, dispatchResult); int newAttemptCount = current.getAttemptCount() + Math.max(consumedAttempts, 1); if (dispatchResult.isAccepted()) { @@ -414,28 +466,32 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService { deliveryMapper.update(success, Wrappers.lambdaUpdate() .eq(ThirdPushDeliveryEntity::getId, current.getId()) .eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING)); - return true; + return; } PushDispatchAttempt lastAttempt = dispatchResult.getAttempts().isEmpty() ? null : dispatchResult.getAttempts().get(dispatchResult.getAttempts().size() - 1); String error = lastAttempt == null ? "NO_PROVIDER_AVAILABLE" : StrUtil.blankToDefault(lastAttempt.getResult().getErrorMessage(), lastAttempt.getResult().getErrorCode()); + applyDispatchFailure(current, lastAttempt, error, newAttemptCount); + } + + private void applyDispatchFailure(ThirdPushDeliveryEntity current, PushDispatchAttempt lastAttempt, + String error, int newAttemptCount) { if (lastAttempt != null && "INVALID_DEVICE".equals(lastAttempt.getResult().getErrorCode())) { deviceBindingService.disableInvalidBindings(current.getRecipientUserId(), lastAttempt.getProviderCode()); } if (newAttemptCount >= AppMessageConstants.MAX_DELIVERY_ATTEMPTS) { markTerminalFailure(current, error, newAttemptCount); - } else { - ThirdPushDeliveryEntity retry = new ThirdPushDeliveryEntity(); - retry.setStatus(AppMessageConstants.DELIVERY_RETRY); - retry.setAttemptCount(newAttemptCount); - retry.setNextRetryTime(LocalDateTime.now().plusMinutes(retryMinutes(newAttemptCount))); - retry.setLastError(StrUtil.sub(error, 0, 2000)); - deliveryMapper.update(retry, Wrappers.lambdaUpdate() - .eq(ThirdPushDeliveryEntity::getId, current.getId()) - .eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING)); + return; } - return true; + ThirdPushDeliveryEntity retry = new ThirdPushDeliveryEntity(); + retry.setStatus(AppMessageConstants.DELIVERY_RETRY); + retry.setAttemptCount(newAttemptCount); + retry.setNextRetryTime(LocalDateTime.now().plusMinutes(retryMinutes(newAttemptCount))); + retry.setLastError(StrUtil.sub(error, 0, 2000)); + deliveryMapper.update(retry, Wrappers.lambdaUpdate() + .eq(ThirdPushDeliveryEntity::getId, current.getId()) + .eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING)); } /** From b5af0e18cd3fc896a68e385de368d3b3bf971526 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Fri, 13 Nov 2026 12:53:55 +0800 Subject: [PATCH 10/10] =?UTF-8?q?1.=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/SmHotelDailyEntity.java | 10 + .../entity/SmHotelOrderEntity.java | 6 + .../SmHotelPriceChannelConfigEntity.java | 57 +++++ .../entity/SmHotelPriceConfigEntity.java | 3 + .../controller/SmHotelMidController.java | 34 ++- .../mapper/SmHotelDailyMapper.java | 6 +- .../SmHotelPriceChannelConfigMapper.java | 9 + .../mapper/SmHotelProductMapper.java | 6 +- .../service/SmHotelPriceConfigService.java | 55 ++++- .../service/impl/SmHotelMidServiceImpl.java | 101 ++++++--- .../impl/SmHotelOpenApiServiceImpl.java | 12 +- .../service/impl/SmHotelOrderServiceImpl.java | 102 +++++++-- .../impl/SmHotelPriceConfigServiceImpl.java | 194 +++++++++++++++--- 13 files changed, 508 insertions(+), 87 deletions(-) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceChannelConfigEntity.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelPriceChannelConfigMapper.java diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDailyEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDailyEntity.java index 2c6774dce..076faf8f5 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDailyEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDailyEntity.java @@ -51,6 +51,16 @@ public class SmHotelDailyEntity extends Model { @Schema(description = "可售剩余") private Integer remainStock; + /** 按全局配置动态算的默认卖价(非表字段) */ + @TableField(exist = false) + @Schema(description = "默认卖价=协议价按全局加价") + private BigDecimal defaultSellPrice; + + /** 各启用下游动态卖价 partnerCode → amount(非表字段) */ + @TableField(exist = false) + @Schema(description = "下游渠道卖价映射") + private java.util.Map channelSellPrices; + @TableField(fill = FieldFill.INSERT) private String createBy; diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java index df09fbc1f..f1fa05879 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java @@ -58,6 +58,12 @@ public class SmHotelOrderEntity extends Model { private BigDecimal markupPercent; + @Schema(description = "下单时加价固定金额快照") + private BigDecimal markupFixed; + + @Schema(description = "下单计价所用下游 partner,空表示全局/App") + private String pricePartnerCode; + private String contactName; private String contactPhone; diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceChannelConfigEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceChannelConfigEntity.java new file mode 100644 index 000000000..1574d362b --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceChannelConfigEntity.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.supermarket.entity; + +import com.baomidou.mybatisplus.annotation.*; +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.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 酒店下游渠道售价加价:有启用配置则对协议价加价;无配置则推/验协议价原价。 + */ +@Data +@TenantTable +@TableName("canteen_hotel_price_channel_config") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "酒店下游渠道售价加价") +public class SmHotelPriceChannelConfigEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + @Schema(description = "下游伙伴编码") + private String partnerCode; + + @Schema(description = "加价百分比:正加价、负折扣") + private BigDecimal markupPercent; + + @Schema(description = "固定加价金额(元)") + private BigDecimal markupFixed; + + @Schema(description = "1启用 0停用") + private String enabled; + + private String remark; + + @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-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceConfigEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceConfigEntity.java index d6d973d4b..86acc9f8b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceConfigEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceConfigEntity.java @@ -29,6 +29,9 @@ public class SmHotelPriceConfigEntity extends Model { @Schema(description = "售价调整百分比:正数加价、负数折扣,例15=加价15%,-10=打九折") private BigDecimal markupPercent; + @Schema(description = "固定加价金额(元),可与百分比叠加:sell=cost*(1+p/100)+fixed") + private BigDecimal markupFixed; + private String remark; @TableField(fill = FieldFill.INSERT) diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java index 3eec03f6b..0d4c3a59e 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.supermarket.dto.SmHotelLocalDailyBatchDTO; import com.bsd.pjcloud.supermarket.dto.SmHotelSyncFailExcel; import com.bsd.pjcloud.supermarket.dto.SmHotelSyncFailLineVO; import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelPriceChannelConfigEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity; @@ -29,6 +30,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -69,13 +71,43 @@ public class SmHotelMidController { } @SysLog("全局同步酒店售价(仅第三方来源,按配置重算产品/日历,不动订单与本地酒店)") - @Operation(summary = "按当前配置重算第三方酒店产品与日历卖价(排除数据来源=本地;不动订单)") + @Operation(summary = "按当前全局配置重算第三方酒店默认卖价(排除本地;不动订单与下游渠道价)") @PostMapping("/priceConfig/resyncSell") @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')") public R> resyncSellPrices() { return priceConfigService.resyncAllSellPrices(); } + @Operation(summary = "下游渠道加价配置列表") + @GetMapping("/priceConfig/channels") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')") + public R> listPriceChannels() { + return priceConfigService.listChannelConfigs(); + } + + @SysLog("保存酒店下游渠道加价") + @Operation(summary = "保存下游渠道加价(有配置则对该伙伴按公式加价;删除后推原价)") + @PutMapping("/priceConfig/channels") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')") + public R savePriceChannel(@RequestBody SmHotelPriceChannelConfigEntity entity) { + return priceConfigService.saveChannelConfig(entity); + } + + @SysLog("删除酒店下游渠道加价") + @Operation(summary = "删除下游渠道加价配置(之后该伙伴推/验协议价原价)") + @DeleteMapping("/priceConfig/channels/{id}") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')") + public R deletePriceChannel(@PathVariable Long id) { + return priceConfigService.deleteChannelConfig(id); + } + + @Operation(summary = "预览协议价在全局/各下游下的卖价") + @GetMapping("/priceConfig/preview") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')") + public R> previewPrice(@RequestParam(required = false) BigDecimal basePrice) { + return priceConfigService.previewSellPrices(basePrice); + } + @Operation(summary = "酒店详情(含已拉取房型/产品/日历预览)") @GetMapping("/detail/{id}") @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") 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 653631302..e01acb315 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 @@ -106,18 +106,18 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper list); /** - * 按底价重算日历卖价:sell = ROUND(cost * rate, 2)。仅第三方来源酒店;本地 sheet_name=local 不参与。 + * 按底价重算日历默认卖价:sell = ROUND(cost * rate + fixed, 2)。仅第三方来源;本地不参与。 */ @Update(""" UPDATE canteen_hotel_daily d INNER JOIN canteen_hotel h ON h.id = d.hotel_id AND h.del_flag = '0' - SET d.sell_price = ROUND(d.cost_price * #{rate}, 2), + SET d.sell_price = GREATEST(0.01, ROUND(d.cost_price * #{rate} + #{fixed}, 2)), d.update_time = NOW() WHERE d.del_flag = '0' AND d.cost_price IS NOT NULL AND (h.sheet_name IS NULL OR LOWER(TRIM(h.sheet_name)) <> 'local') """) - int resyncSellPriceByCost(@Param("rate") BigDecimal rate); + int resyncSellPriceByCost(@Param("rate") BigDecimal rate, @Param("fixed") BigDecimal fixed); @Select("""