This commit is contained in:
dzc committed 2026-11-13 13:01:28 +08:00
commit 5a3236558c
58 files changed
+2972 -417

No files matched your search

@@ -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<SmHotelFxInvitePreviewVO> preview(@RequestParam("inviteCode") String inviteCode);
@NoToken
@PostMapping("/inner/smHotelFx/invite/bindAgent")
R<Boolean> 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);
}
@@ -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;
}
@@ -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;
}
@@ -51,6 +51,16 @@ public class SmHotelDailyEntity extends Model<SmHotelDailyEntity> {
@Schema(description = "可售剩余")
private Integer remainStock;
/** 按全局配置动态算的默认卖价(非表字段) */
@TableField(exist = false)
@Schema(description = "默认卖价=协议价按全局加价")
private BigDecimal defaultSellPrice;
/** 各启用下游动态卖价 partnerCode → amount(非表字段) */
@TableField(exist = false)
@Schema(description = "下游渠道卖价映射")
private java.util.Map<String, BigDecimal> channelSellPrices;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@@ -56,6 +56,15 @@ public class SmHotelFxAgentEntity extends Model<SmHotelFxAgentEntity> {
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;
@@ -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<SmHotelFxHotelApplyEntity> {
@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;
}
@@ -33,6 +33,9 @@ public class SmHotelMgrPushConfigEntity extends Model<SmHotelMgrPushConfigEntity
@Schema(description = "新订单推送正文,可含 {hotelName}/{channelNo}")
private String newOrderContent;
@Schema(description = "新订单同时推送 ROLE_ADMIN:1开 0关")
private String newOrderAdminEnabled;
@Schema(description = "日历提醒:1开 0关")
private String calendarRemindEnabled;
@@ -79,6 +79,9 @@ public class SmHotelMgrPushLogEntity extends Model<SmHotelMgrPushLogEntity> {
private String resultMsg;
@Schema(description = "下游请求体快照(失败/部分失败时写入 JSON)")
private String requestJson;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@@ -58,13 +58,19 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
private BigDecimal markupPercent;
@Schema(description = "下单时加价固定金额快照")
private BigDecimal markupFixed;
@Schema(description = "下单计价所用下游 partner,空表示全局/App")
private String pricePartnerCode;
private String contactName;
private String contactPhone;
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")
@@ -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<SmHotelPriceChannelConfigEntity> {
@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;
}
@@ -29,6 +29,9 @@ public class SmHotelPriceConfigEntity extends Model<SmHotelPriceConfigEntity> {
@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)
@@ -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;
}
@@ -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;
}
@@ -47,16 +47,23 @@ public class SmHotelController {
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<Page<SmHotelEntity>> page(@ParameterObject Page<SmHotelEntity> page,
@ParameterObject SmHotelEntity query) {
String nameKw = StrUtil.trim(query.getHotelName());
boolean nameSearch = StrUtil.isNotBlank(nameKw);
var wrapper = Wrappers.<SmHotelEntity>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<SmHotelEntity> prefixPage = hotelService.pageScopeWithoutCount(page, wrapper);
if (page.getCurrent() > 1 || (prefixPage.getRecords() != null && !prefixPage.getRecords().isEmpty())) {
return R.ok(prefixPage);
}
var contains = Wrappers.<SmHotelEntity>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 */
@@ -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<java.util.List<SmHotelFxHotelBindEntity>> bindsByHotels(@RequestParam String hotelIds) {
java.util.List<Long> 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<Map<String, Object>> mySummary() {
return commissionService.mySummary();
R<Map<String, Object>> 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<Map<String, Object>> downlinePerf() {
return commissionService.downlinePerf();
}
@SysLog("分销模拟下单")
@Operation(summary = "模拟下单记佣(管理端联调:酒店须已绑定分销员)")
@PostMapping("/mockOrder")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelFx_edit')")
public R<Map<String, Object>> 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<SmHotelFxL1ApplyEntity> appL1Apply(@RequestParam String cityAdcode,
@@ -238,4 +286,90 @@ public class SmHotelFxController {
public R<SmHotelFxExitEntity> appExitApply(@RequestParam(required = false) String reason) {
return exitService.applyMine(reason);
}
@Operation(summary = "酒店提报分页(管理端)")
@GetMapping("/hotelApply/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelFxHotelApply_view')")
public R<Page<SmHotelFxHotelApplyEntity>> hotelApplyPage(Page<SmHotelFxHotelApplyEntity> page,
SmHotelFxHotelApplyEntity query) {
return R.ok(hotelApplyService.pageAdmin(page, query));
}
@SysLog("审核分销酒店提报")
@Operation(summary = "审核酒店提报(通过须选已完善酒店 + 备注账号密码)")
@PostMapping("/hotelApply/audit")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelFxHotelApply_audit')")
public R<Boolean> 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<Page<SmHotelFxHotelApplyEntity>> appHotelApplyPage(Page<SmHotelFxHotelApplyEntity> page,
@RequestParam(required = false) String status) {
return R.ok(hotelApplyService.pageMine(page, status));
}
@Operation(summary = "我的提报详情(App,编辑回填)")
@GetMapping("/app/hotelApply/{id}")
public R<SmHotelFxHotelApplyEntity> appHotelApplyDetail(@PathVariable Long id) {
return hotelApplyService.getMine(id);
}
@Operation(summary = "新增/重提酒店(App:名称+地址+营业执照;传 id 可编辑待审/驳回)")
@PostMapping("/app/hotelApply")
public R<SmHotelFxHotelApplyEntity> appHotelApply(@RequestBody Map<String, Object> 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<String, Object> 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<Page<SmHotelFxHotelBindEntity>> appMyHotels(Page<SmHotelFxHotelBindEntity> page) {
return R.ok(agentService.pageMineHotels(page));
}
}
@@ -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<SmHotelFxInvitePreviewVO> preview(@RequestParam String inviteCode) {
return agentService.previewInvite(inviteCode);
}
@Inner
@Operation(summary = "注册后挂靠分销员")
@PostMapping("/bindAgent")
public R<Boolean> 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);
}
}
@@ -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<Map<String, Object>> resyncSellPrices() {
return priceConfigService.resyncAllSellPrices();
}
@Operation(summary = "下游渠道加价配置列表")
@GetMapping("/priceConfig/channels")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')")
public R<List<SmHotelPriceChannelConfigEntity>> listPriceChannels() {
return priceConfigService.listChannelConfigs();
}
@SysLog("保存酒店下游渠道加价")
@Operation(summary = "保存下游渠道加价(有配置则对该伙伴按公式加价;删除后推原价)")
@PutMapping("/priceConfig/channels")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')")
public R<Boolean> savePriceChannel(@RequestBody SmHotelPriceChannelConfigEntity entity) {
return priceConfigService.saveChannelConfig(entity);
}
@SysLog("删除酒店下游渠道加价")
@Operation(summary = "删除下游渠道加价配置(之后该伙伴推/验协议价原价)")
@DeleteMapping("/priceConfig/channels/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')")
public R<Boolean> deletePriceChannel(@PathVariable Long id) {
return priceConfigService.deleteChannelConfig(id);
}
@Operation(summary = "预览协议价在全局/各下游下的卖价")
@GetMapping("/priceConfig/preview")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')")
public R<Map<String, Object>> previewPrice(@RequestParam(required = false) BigDecimal basePrice) {
return priceConfigService.previewSellPrices(basePrice);
}
@Operation(summary = "酒店详情(含已拉取房型/产品/日历预览)")
@GetMapping("/detail/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
@@ -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 在「渠道对接配置」各自维护):
* <pre>
* POST /third/party/nice/order/createOrder
* POST /third/party/nice/order/orderCancel
* POST /third/party/nice/order/orderConfirmTimeOut
* </pre>
* 网关完整路径示例: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<String, Object> createOrder(HttpServletRequest request, @RequestBody String rawBody) {
return handle(request, "inbound_create", rawBody, shengnuoOrderService::createOrder);
}
@Inner(value = false)
@Operation(summary = "奈斯推送取消")
@PostMapping("/orderCancel")
public Map<String, Object> orderCancel(HttpServletRequest request, @RequestBody String rawBody) {
return handle(request, "inbound_cancel", rawBody, shengnuoOrderService::orderCancel);
}
@Inner(value = false)
@Operation(summary = "奈斯确认超时提醒")
@PostMapping("/orderConfirmTimeOut")
public Map<String, Object> orderConfirmTimeOut(HttpServletRequest request, @RequestBody String rawBody) {
return handle(request, "inbound_timeout", rawBody, shengnuoOrderService::orderConfirmTimeOut);
}
private Map<String, Object> handle(HttpServletRequest request, String action, String rawBody,
BiFunction<String, JSONObject, Map<String, Object>> fn) {
long t0 = System.currentTimeMillis();
String partner = null;
Map<String, Object> 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;
}
}
@@ -22,12 +22,13 @@ import java.util.Map;
import java.util.function.BiFunction;
/**
* 圣诺/奈斯订单入站(供应商回调地址,给对方配置):
* 圣诺订单入站(供应商回调地址,给对方配置):
* <pre>
* POST /third/party/shengnuo/order/createOrder
* POST /third/party/shengnuo/order/orderCancel
* POST /third/party/shengnuo/order/orderConfirmTimeOut
* </pre>
* 奈斯同协议另见 {@link SmHotelNiceOrderController}(/third/party/nice/order/*)。
* 网关完整路径示例:https://域名/api/supermarket/third/party/shengnuo/order/createOrder
*/
@Slf4j
@@ -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) {
@@ -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("签名错误");
@@ -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) {
@@ -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
@@ -30,6 +30,10 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper<SmHotelDailyEntity
int physicalDeleteRange(@Param("hotelId") Long hotelId, @Param("fromDate") LocalDate fromDate,
@Param("toExclusive") LocalDate toExclusive);
/** 清理过期日历(upsert 落库时顺带收口,避免关房行无限堆积) */
@Delete("DELETE FROM canteen_hotel_daily WHERE hotel_id = #{hotelId} AND stay_date < #{beforeDate}")
int physicalDeleteBefore(@Param("hotelId") Long hotelId, @Param("beforeDate") LocalDate beforeDate);
/**
* 移动端列表:入住半开区间内有日历的酒店(DISTINCT,避免相关子查询扫大表)。
*/
@@ -102,18 +106,18 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper<SmHotelDailyEntity
int insertBatch(@Param("list") List<SmHotelDailyEntity> 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("""
<script>
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelFxHotelApplyEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelFxHotelApplyMapper extends PjcloudBaseMapper<SmHotelFxHotelApplyEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceChannelConfigEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelPriceChannelConfigMapper extends PjcloudBaseMapper<SmHotelPriceChannelConfigEntity> {
}
@@ -18,18 +18,18 @@ public interface SmHotelProductMapper extends PjcloudBaseMapper<SmHotelProductEn
int insertBatch(@Param("list") List<SmHotelProductEntity> list);
/**
* 按底价重算卖价:sell = ROUND(cost * rate, 2)。仅第三方来源酒店;本地 sheet_name=local 不参与。
* 按底价重算默认卖价:sell = ROUND(cost * rate + fixed, 2)。仅第三方来源;本地不参与。
*/
@Update("""
UPDATE canteen_hotel_product p
INNER JOIN canteen_hotel h ON h.id = p.hotel_id AND h.del_flag = '0'
SET p.sell_price = ROUND(p.cost_price * #{rate}, 2),
SET p.sell_price = GREATEST(0.01, ROUND(p.cost_price * #{rate} + #{fixed}, 2)),
p.update_time = NOW()
WHERE p.del_flag = '0'
AND p.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("""
<script>
@@ -23,10 +23,22 @@ public interface SmHotelFxAgentService extends IService<SmHotelFxAgentEntity> {
Page<SmHotelFxHotelBindEntity> pageBinds(Page<SmHotelFxHotelBindEntity> page, Long agentId, Long hotelId);
/** 批量查酒店当前 BOUND 绑定(审核选店用) */
java.util.List<SmHotelFxHotelBindEntity> listBoundByHotelIds(java.util.Collection<Long> hotelIds);
/** App:当前分销员已绑定酒店 */
Page<SmHotelFxHotelBindEntity> pageMineHotels(Page<SmHotelFxHotelBindEntity> page);
SmHotelFxAgentEntity getByUserId(Long userId);
SmHotelFxAgentEntity getBoundAgentByHotelId(Long hotelId);
/** 分销邀请码预览;无效返回 failed */
R<com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO> previewInvite(String inviteCode);
/** 注册成功后按邀请码创建分销员并挂上级 */
R<Boolean> createAgentByInvite(String inviteCode, Long userId, String username, String realName, String mobile);
/** 锁定佣金后:累加终身晋升分,并尝试 4→3 / 3→2 自动晋升 */
void onPromoteBaseLocked(Long ownerAgentId, BigDecimal promoteBaseAmount);
@@ -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<SmHotelFxCommission
/** App:当前登录用户作为分销员的佣金汇总 + 分页 */
R<Map<String, Object>> mySummary();
/** App:直接下级绩效汇总(佣金归下级本人,上级仅查看) */
R<Map<String, Object>> downlinePerf();
Page<SmHotelFxCommissionEntity> pageMine(Page<SmHotelFxCommissionEntity> page, String status);
/**
* 管理端联调:对已绑定分销员的酒店模拟一笔订单并记预计佣金。
* lockNow=true 时退房日设为昨天并立即锁定,便于测提现链路。
*/
R<Map<String, Object>> mockOrderForBoundHotel(Long hotelId, BigDecimal costAmount, Boolean lockNow);
}
@@ -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<SmHotelFxHotelApplyEntity> {
Page<SmHotelFxHotelApplyEntity> pageAdmin(Page<SmHotelFxHotelApplyEntity> page, SmHotelFxHotelApplyEntity query);
Page<SmHotelFxHotelApplyEntity> pageMine(Page<SmHotelFxHotelApplyEntity> page, String status);
R<SmHotelFxHotelApplyEntity> applyMine(SmHotelFxHotelApplyEntity body);
/** App:我的提报详情(编辑回填) */
R<SmHotelFxHotelApplyEntity> getMine(Long id);
/** 当前分销员待审提报数 */
long countPendingMine();
/**
* 审核。通过时必须传 hotelId(后台已完善的酒店)和 auditRemark(含账号密码,分销员可见)。
*/
R<Boolean> audit(Long id, boolean approved, String auditRemark, Long hotelId);
}
@@ -1,9 +1,11 @@
package com.bsd.pjcloud.supermarket.service;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceChannelConfigEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
public interface SmHotelPriceConfigService {
@@ -12,18 +14,53 @@ public interface SmHotelPriceConfigService {
R<Boolean> saveOrUpdateConfig(SmHotelPriceConfigEntity entity);
/** 当前售价调整百分比(正加价/负折扣),未配置返回 0 */
/** 当前全局加价百分比(正加价/负折扣),未配置返回 0 */
BigDecimal currentMarkupPercent();
/** 底价按当前配置计算卖价:cost × (1 + percent/100) */
BigDecimal applyMarkup(BigDecimal costPrice);
/** 用指定百分比计算卖价(不读库) */
BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent);
/** 当前全局固定加价金额,未配置返回 0 */
BigDecimal currentMarkupFixed();
/**
* 按当前配置重算全部产品/日历卖价(仅 sell_price,不动订单)。
* @return productUpdated / dailyUpdated / markupPercent
* 底价按全局配置计算默认卖价(App/无 partner):cost × (1 + p/100) + fixed。
*/
BigDecimal applyMarkup(BigDecimal costPrice);
/** 兼容旧调用:仅百分比 */
BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent);
/** 统一公式:sell = ROUND(base × (1 + percent/100) + fixed, 2),下限 0.01 */
BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent, BigDecimal fixed);
/**
* 按下游伙伴解析卖价(2A):
* <ul>
* <li>partner 有启用渠道配置 → 仅用该渠道 percent/fixed 作用在底价</li>
* <li>partner 非空但无渠道配置 → 返回底价原价</li>
* <li>partner 为空(App/内部)→ 用全局 percent/fixed</li>
* </ul>
*/
BigDecimal resolveSellPrice(BigDecimal basePrice, String partnerCode);
/** 解析某伙伴实际使用的 percent/fixed;无渠道配置且有 partner 时 percent=0,fixed=0(原价) */
MarkupRule resolveMarkupRule(String partnerCode);
R<List<SmHotelPriceChannelConfigEntity>> listChannelConfigs();
R<Boolean> saveChannelConfig(SmHotelPriceChannelConfigEntity entity);
R<Boolean> deleteChannelConfig(Long id);
/**
* 对给定协议价预览:默认卖价 + 各已启用下游卖价。
*/
R<Map<String, Object>> previewSellPrices(BigDecimal basePrice);
/**
* 按全局配置重算落库默认卖价(仅 sell_price,不动订单;不含下游渠道价)。
*/
R<Map<String, Object>> resyncAllSellPrices();
/** 加价规则快照 */
record MarkupRule(BigDecimal percent, BigDecimal fixed, boolean fromChannel, String partnerCode) {
}
}
@@ -12,6 +12,12 @@ public interface SmHotelService extends IService<SmHotelEntity> {
Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page, LambdaQueryWrapper<SmHotelEntity> wrapper);
/**
* 同 {@link #pageScope},但不做 COUNT(*)(店名模糊搜索时 COUNT+LIKE 会扫十几万行超时)。
* total 用 hasMore 估算,便于前端翻页。
*/
Page<SmHotelEntity> pageScopeWithoutCount(Page<SmHotelEntity> page, LambdaQueryWrapper<SmHotelEntity> wrapper);
/**
* 管理端列表可售筛选:1/SALEABLE=近窗有开房可售库存;0/UNSALEABLE=近窗无可售。
*/
@@ -409,6 +409,9 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl<SmHotelChannelC
if (dto.getResourceAutoPushEnabled() != null) {
target.setResourceAutoPushEnabled(dto.getResourceAutoPushEnabled());
}
if (dto.getResourcePushConcurrency() != null && dto.getResourcePushConcurrency() > 0) {
target.setResourcePushConcurrency(dto.getResourcePushConcurrency());
}
}
private void overlayShangyou(ShangyouHotelProperties target, SmHotelShangyouChannelConfigDTO dto) {
@@ -59,7 +59,8 @@ public class SmHotelDownstreamInboundRetryServiceImpl implements SmHotelDownstre
long t0 = System.currentTimeMillis();
Map<String, Object> 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)) {
@@ -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<SmHotelFxAgentMapper,
private final SmHotelFxConfigService configService;
/** 姓名/手机以系统用户为准,冗余字段保存与列表展示时同步刷新 */
private final RemoteUserService remoteUserService;
@Override
public Page<SmHotelFxAgentEntity> pageAgents(Page<SmHotelFxAgentEntity> page, SmHotelFxAgentEntity query) {
Page<SmHotelFxAgentEntity> result = page(page, Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
@@ -62,6 +71,8 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
if (list == null || list.isEmpty()) {
return;
}
// 列表展示跟系统用户最新资料对齐,并回写已漂移的冗余字段
syncProfileFromUsers(list, true);
List<Long> parentIds = list.stream().map(SmHotelFxAgentEntity::getParentId).filter(Objects::nonNull).distinct()
.toList();
Map<Long, String> parentName = Map.of();
@@ -82,6 +93,74 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
}
}
/** 按 userId 拉系统用户,覆盖账号/姓名/手机;persist=true 时把变更写回分销员表 */
private void syncProfileFromUsers(List<SmHotelFxAgentEntity> agents, boolean persist) {
if (CollUtil.isEmpty(agents)) {
return;
}
List<Long> userIds = agents.stream().map(SmHotelFxAgentEntity::getUserId).filter(Objects::nonNull).distinct()
.toList();
if (userIds.isEmpty()) {
return;
}
Map<Long, SysUser> userMap = loadUserMap(userIds);
if (userMap.isEmpty()) {
return;
}
List<SmHotelFxAgentEntity> 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<Long, SysUser> loadUserMap(List<Long> userIds) {
try {
R<List<SysUser>> resp = remoteUserService.getInnerUserById(userIds);
List<SysUser> 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<Long, SysUser> 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<SmHotelFxAgentEntity> saveAgent(SmHotelFxAgentEntity body) {
@@ -96,21 +175,28 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
if (StrUtil.isBlank(body.getStatus())) {
body.setStatus("ACTIVE");
}
// 姓名/账号/手机一律跟系统用户,不吃前端手填
applyUserProfile(body);
if (StrUtil.isBlank(body.getRealName())) {
return R.failed("系统用户缺少姓名,请先在用户管理补全");
}
if (body.getId() == null) {
Long exists = count(Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
.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 ServiceImpl<SmHotelFxAgentMapper,
if (old == null) {
return R.failed("分销员不存在");
}
// 允许纠正 userId(前端曾 Number() 丢精度时需重绑);换绑时不可撞到其他分销员
if (body.getUserId() != null && !Objects.equals(body.getUserId(), old.getUserId())) {
Long clash = count(Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
.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<SmHotelFxAgentMapper,
row.setAuditRemark(remark);
row.setCreateTime(now);
bindMapper.insert(row);
return R.ok(Boolean.TRUE);
}
else {
boolean rebound = !"BOUND".equals(exist.getBindStatus())
|| !Objects.equals(exist.getAgentId(), agentId);
exist.setAgentId(agentId);
exist.setBindStatus("BOUND");
exist.setAuditRemark(remark);
// 解绑后再绑 / 换绑:视为「新审核酒店」,保级窗口可计入
if (rebound) {
exist.setCreateTime(now);
}
bindMapper.updateById(exist);
// 一店一员:已绑定其他分销员不可直接换绑(须先解绑或走退出重分配)
if ("BOUND".equals(exist.getBindStatus()) && !Objects.equals(exist.getAgentId(), agentId)) {
SmHotelFxAgentEntity occupied = getById(exist.getAgentId());
String who = occupied == null ? String.valueOf(exist.getAgentId())
: StrUtil.blankToDefault(occupied.getRealName(), occupied.getUsername());
return R.failed("该酒店已绑定分销员「" + who + "」,请先解绑后再绑定");
}
boolean rebound = !"BOUND".equals(exist.getBindStatus());
exist.setAgentId(agentId);
exist.setBindStatus("BOUND");
exist.setAuditRemark(remark);
// 解绑后再绑:视为「新审核酒店」,保级窗口可计入
if (rebound) {
exist.setCreateTime(now);
}
bindMapper.updateById(exist);
return R.ok(Boolean.TRUE);
}
@@ -247,7 +351,44 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
.eq(hotelId != null, SmHotelFxHotelBindEntity::getHotelId, hotelId)
.eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND")
.orderByDesc(SmHotelFxHotelBindEntity::getId));
for (SmHotelFxHotelBindEntity b : result.getRecords()) {
fillBindDisplay(result.getRecords());
return result;
}
@Override
public List<SmHotelFxHotelBindEntity> listBoundByHotelIds(Collection<Long> hotelIds) {
if (hotelIds == null || hotelIds.isEmpty()) {
return Collections.emptyList();
}
List<Long> ids = hotelIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
if (ids.isEmpty()) {
return Collections.emptyList();
}
List<SmHotelFxHotelBindEntity> list = bindMapper.selectList(Wrappers.<SmHotelFxHotelBindEntity>lambdaQuery()
.in(SmHotelFxHotelBindEntity::getHotelId, ids)
.eq(SmHotelFxHotelBindEntity::getBindStatus, "BOUND"));
fillBindDisplay(list);
return list;
}
@Override
public Page<SmHotelFxHotelBindEntity> pageMineHotels(Page<SmHotelFxHotelBindEntity> 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<SmHotelFxHotelBindEntity> 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<SmHotelFxAgentMapper,
b.setAgentName(StrUtil.blankToDefault(a.getRealName(), a.getUsername()));
}
}
return result;
}
@Override
@@ -291,6 +431,94 @@ public class SmHotelFxAgentServiceImpl extends ServiceImpl<SmHotelFxAgentMapper,
return agent;
}
@Override
public R<com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO> 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<Long, SysUser> 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<Boolean> createAgentByInvite(String inviteCode, Long userId, String username, String realName,
String mobile) {
if (userId == null) {
return R.failed("用户为空");
}
SmHotelFxAgentEntity exist = getOne(Wrappers.<SmHotelFxAgentEntity>lambdaQuery()
.eq(SmHotelFxAgentEntity::getUserId, userId)
.last("limit 1"), false);
if (exist != null) {
return R.failed("该用户已是分销员");
}
R<com.bsd.pjcloud.supermarket.vo.SmHotelFxInvitePreviewVO> 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.<SmHotelFxAgentEntity>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) {
@@ -95,18 +95,18 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
.collect(Collectors.toMap(SmHotelFxLevelRuleDTO::getLevel, r -> r, (a, b) -> a));
// 开发员向上链路:每人只拿「自身当前层级」对应份额(缺层则该份额不发)
// 特例:五级开发酒店 → 开发佣金归五级(按四级份额);其四级上级本单不拿佣金(仅下线绩效)
List<SmHotelFxAgentEntity> chain = buildUplineChain(owner);
Map<String, Object> snapshot = buildSnapshot(cfg, rules, cost, pool, promoteBase, owner.getId());
List<SmHotelFxCommissionEntity> 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<SmHotelFxCommiss
row.setAgentLevel(lv);
row.setCostAmount(cost);
row.setPoolAmount(pool);
row.setShareRate(rule.getShareOfPool());
row.setShareRate(share);
row.setCommissionAmount(amount);
row.setPromoteBaseAmount(promoteBase);
row.setStatus(ST_EST);
row.setCheckOutDate(order.getCheckOutDate());
row.setConfigSnapshot(JSONUtil.toJsonStr(snapshot));
row.setRemark("预计佣金");
row.setRemark(Objects.equals(agent.getId(), owner.getId()) && Integer.valueOf(5).equals(owner.getLevelNo())
? "预计佣金(五级开发按四级份额)"
: "预计佣金");
rows.add(row);
}
if (rows.isEmpty()) {
@@ -136,6 +138,37 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
rows.size(), pool);
}
/**
* 解析某人在本单应得份额。
* <ul>
* <li>开发员为五级:本人按四级 shareOfPool 计开发佣金</li>
* <li>开发员为五级:链上四级上级本单份额跳过(佣金归五级,四级只看下线绩效)</li>
* <li>其余:按自身 level 的 shareOfPool</li>
* </ul>
*/
private BigDecimal resolveShareOfPool(SmHotelFxAgentEntity owner, SmHotelFxAgentEntity agent,
Map<Integer, SmHotelFxLevelRuleDTO> 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<SmHotelFxAgentEntity> buildUplineChain(SmHotelFxAgentEntity owner) {
List<SmHotelFxAgentEntity> chain = new ArrayList<>();
@@ -276,6 +309,12 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
data.put("agentId", me.getId());
data.put("levelNo", me.getLevelNo());
data.put("realName", me.getRealName());
data.put("username", me.getUsername());
data.put("mobile", me.getMobile());
data.put("inviteCode", me.getInviteCode());
data.put("payMethod", me.getPayMethod());
data.put("payAccount", me.getPayAccount());
data.put("payAccountName", me.getPayAccountName());
data.put("promoteScore", me.getPromoteScore());
data.put("cityAdcode", me.getCityAdcode());
data.put("demoteWindowScore", me.getDemoteWindowScore());
@@ -286,16 +325,35 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
data.put("withdrawableAmount", withdrawable);
data.put("withdrawnAmount", withdrawn);
data.put("totalAmount", estimated.add(locked));
// 首页卡片:已绑酒店数
data.put("hotelCount", agentService.pageBinds(new Page<>(1, 1), me.getId(), null).getTotal());
List<SmHotelFxLevelRuleDTO> 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<SmHotelFxCommiss
return R.ok(data);
}
@Override
public R<Map<String, Object>> downlinePerf() {
SmHotelFxAgentEntity me = currentAgent();
if (me == null) {
return R.failed("当前账号不是分销员");
}
List<SmHotelFxAgentEntity> children = agentService.list(Wrappers.<SmHotelFxAgentEntity>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<Map<String, Object>> items = new ArrayList<>();
for (SmHotelFxAgentEntity child : children) {
List<SmHotelFxCommissionEntity> commissions = list(Wrappers.<SmHotelFxCommissionEntity>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<String, Object> 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<String, Object> 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<SmHotelFxCommissionEntity> pageMine(Page<SmHotelFxCommissionEntity> page, String status) {
SmHotelFxAgentEntity me = currentAgent();
@@ -325,7 +447,13 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
if (user == null || user.getId() == null) {
return null;
}
return agentService.getByUserId(user.getId());
SmHotelFxAgentEntity agent = agentService.getByUserId(user.getId());
if (agent == null) {
// 常见原因:PC 保存时 Number(userId) 精度丢失,agent.user_id 与登录用户不一致
log.warn("|酒店分销|currentAgent未命中|userId={}|username={}|tenantId={}|", user.getId(),
user.getUsername(), user.getTenantId());
}
return agent;
}
private void fillDisplay(List<SmHotelFxCommissionEntity> list) {
@@ -354,6 +482,109 @@ public class SmHotelFxCommissionServiceImpl extends ServiceImpl<SmHotelFxCommiss
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Map<String, Object>> 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<SmHotelFxCommissionEntity> lines = list(Wrappers.<SmHotelFxCommissionEntity>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<Long, BigDecimal> promoteAdd = new HashMap<>();
List<SmHotelFxCommissionEntity> 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<Long, BigDecimal> 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<String, Object> 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;
}
@@ -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<SmHotelFxHotelApplyMapper, SmHotelFxHotelApplyEntity>
implements SmHotelFxHotelApplyService {
private final SmHotelFxAgentService agentService;
private final SmHotelMapper hotelMapper;
@Override
public Page<SmHotelFxHotelApplyEntity> pageAdmin(Page<SmHotelFxHotelApplyEntity> page,
SmHotelFxHotelApplyEntity query) {
Page<SmHotelFxHotelApplyEntity> result = page(page, Wrappers.<SmHotelFxHotelApplyEntity>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<SmHotelFxHotelApplyEntity> pageMine(Page<SmHotelFxHotelApplyEntity> page, String status) {
SmHotelFxAgentEntity me = requireActiveAgent();
if (me == null) {
return page;
}
Page<SmHotelFxHotelApplyEntity> result = page(page, Wrappers.<SmHotelFxHotelApplyEntity>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<SmHotelFxHotelApplyEntity> 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.<SmHotelFxHotelApplyEntity>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<SmHotelFxHotelApplyEntity> 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<Boolean> 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<Boolean> 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.<SmHotelFxHotelApplyEntity>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<SmHotelFxHotelApplyEntity> 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<SmHotelFxHotelApplyEntity> 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);
}
}
}
}
@@ -199,7 +199,11 @@ public class SmHotelFxWithdrawServiceImpl extends ServiceImpl<SmHotelFxWithdrawM
if (user == null || user.getId() == null) {
return null;
}
return agentService.getByUserId(user.getId());
SmHotelFxAgentEntity agent = agentService.getByUserId(user.getId());
if (agent == null) {
log.warn("|酒店分销|提现currentAgent未命中|userId={}|username={}|", user.getId(), user.getUsername());
}
return agent;
}
private void fillAgent(List<SmHotelFxWithdrawEntity> list) {
@@ -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<String, Object> 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());
}
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
@@ -20,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;
@@ -78,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() {
@@ -101,6 +106,8 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
current.setNewOrderEnabled(normalizeFlag(body.getNewOrderEnabled(), current.getNewOrderEnabled()));
current.setNewOrderTitle(StrUtil.blankToDefault(StrUtil.trim(body.getNewOrderTitle()), DEFAULT_NEW_TITLE));
current.setNewOrderContent(StrUtil.blankToDefault(StrUtil.trim(body.getNewOrderContent()), DEFAULT_NEW_CONTENT));
current.setNewOrderAdminEnabled(
normalizeFlag(body.getNewOrderAdminEnabled(), current.getNewOrderAdminEnabled()));
current.setCalendarRemindEnabled(
normalizeFlag(body.getCalendarRemindEnabled(), current.getCalendarRemindEnabled()));
current.setCalendarRemindTitle(
@@ -176,16 +183,28 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
private PushResult doPushNewOrder(Long orderId, boolean force) {
if (orderId == null) {
return PushResult.fail("订单ID为空");
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("订单不存在:" + orderId);
return PushResult.fail("订单不存在");
}
if ("CREATED".equals(order.getOrderStatus()) && "UNPAID".equals(order.getPayStatus())) {
return force ? PushResult.fail("订单待支付,暂不推送") : PushResult.skip("待支付跳过");
@@ -204,26 +223,43 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
String title = StrUtil.blankToDefault(cfg.getNewOrderTitle(), DEFAULT_NEW_TITLE);
String contentTpl = StrUtil.blankToDefault(cfg.getNewOrderContent(), DEFAULT_NEW_CONTENT);
String trigger = force ? SmHotelMgrPushLogEntity.TRIGGER_MANUAL : SmHotelMgrPushLogEntity.TRIGGER_AUTO;
if (deptId == null) {
String tip = "酒店未绑定部门(hotelId=" + order.getHotelId()
+ ")。请在酒店信息里配置所属部门后再推。";
log.info("|酒店管理推送|新订单跳过无部门|orderId={}|hotelId={}|", orderId, order.getHotelId());
String channelNoVal = StrUtil.blankToDefault(order.getChannelNo(), String.valueOf(orderId));
String orderLabel = StrUtil.isNotBlank(order.getChannelNo()) ? order.getChannelNo() : ("订单#" + orderId);
boolean adminEnabled = ENABLED.equals(cfg.getNewOrderAdminEnabled());
List<Long> leaders = deptId == null ? List.of() : resolveDeptLeaders(deptId);
List<Long> admins = adminEnabled ? resolveUsersByRoleCode(CommonRoleConstant.ROLE_ADMIN) : List.of();
LinkedHashSet<Long> recipients = new LinkedHashSet<>();
recipients.addAll(leaders);
recipients.addAll(admins);
List<Long> 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<Long> 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, null);
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 +270,7 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
payload.put("channelNo", channelNoVal);
payload.put("deptId", deptId);
payload.put("force", force);
payload.put("adminPush", adminEnabled);
Map<String, Object> linkParams = new HashMap<>(4);
linkParams.put("orderId", String.valueOf(orderId));
String eventPrefix = force
@@ -241,7 +278,9 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
: ("HOTEL_MGR_ORDER:" + orderId);
int ok = 0;
List<String> failHints = new ArrayList<>();
for (Long userId : leaders) {
List<Map<String, Object>> failRequests = new ArrayList<>();
HotelManagerPushEventDto sampleRequest = null;
for (Long userId : recipients) {
if (userId == null) {
continue;
}
@@ -259,31 +298,48 @@ 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("userId=" + 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);
log.info("|酒店管理推送|新订单完成|orderId={}|deptId={}|leaders={}|ok={}|force={}|leadersIds={}|",
orderId, deptId, leaders.size(), ok, force, leaders);
List<Long> 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);
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) {
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,
requestJson);
return force ? PushResult.fail(tip) : PushResult.skip(tip);
}
String msg = "已推送成功 " + ok + "/" + leaders.size() + " 人(订单ID=" + orderId + ",deptId=" + deptId
+ ",负责人=" + leaders + ")";
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, 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,
requestJson);
return PushResult.success(ok, recipientList.size(), msg);
}
catch (Exception e) {
log.warn("|酒店管理推送|新订单异常|orderId={}|err={}|", orderId, e.getMessage(), e);
@@ -324,11 +380,12 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
}
}
if (leaders.isEmpty()) {
List<Long> 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);
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());
@@ -341,6 +398,8 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
: ("HOTEL_MGR_CAL:" + day + ":" + hour);
int ok = 0;
List<String> failHints = new ArrayList<>();
List<Map<String, Object>> failRequests = new ArrayList<>();
HotelManagerPushEventDto sampleRequest = null;
for (Long userId : leaders) {
if (userId == null) {
continue;
@@ -357,34 +416,145 @@ 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("userId=" + 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);
log.info("|酒店管理推送|日历提醒完成|hour={}|deptCount={}|leaders={}|ok={}|ids={}|", hour, deptIds.size(),
leaders.size(), ok, leaders);
List<Long> 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<Long> 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, requestJson);
return force ? PushResult.fail(tip) : PushResult.skip(tip);
}
String msg = "日历提醒成功 " + ok + "/" + leaders.size() + " 人(负责人=" + leaders + ")";
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, new ArrayList<>(leaders), ok, fail, title, content, msg);
logDepts, leaderList, ok, fail, title, content, msg, requestJson);
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<Long> resolveUsersByRoleCode(String roleCode) {
if (StrUtil.isBlank(roleCode)) {
return List.of();
}
try {
R<List<Long>> 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<Long> 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));
}
/**
* 组装下游排查快照:有失败时优先写失败请求;全部成功也保留 1 条样例请求便于对照。
*/
private static String buildRequestSnapshot(HotelManagerPushEventDto sample, List<Map<String, Object>> failItems,
int ok, int fail) {
if (sample == null && CollUtil.isEmpty(failItems)) {
return null;
}
Map<String, Object> 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<String, Object> buildFailRequestItem(String recipientName, String error,
HotelManagerPushEventDto dto) {
Map<String, Object> item = new HashMap<>(4);
item.put("recipientName", recipientName);
item.put("error", error);
item.put("request", dto);
return item;
}
/** 是否已有该订单的自动新订单成功/部分成功记录(补偿重试不应再刷手机与推送日志) */
private boolean alreadyAutoPushedNewOrder(Long orderId) {
if (orderId == null) {
return false;
}
Long cnt = pushLogMapper.selectCount(Wrappers.<SmHotelMgrPushLogEntity>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<Long> deptIds, List<Long> userIds, int success, int fail, String title, String content,
String resultMsg) {
String resultMsg, String requestJson) {
try {
SmHotelMgrPushLogEntity row = new SmHotelMgrPushLogEntity();
row.setPushType(pushType);
@@ -401,6 +571,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);
}
@@ -500,6 +671,21 @@ public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
}
}
/** 入箱后立刻扫一轮极光投递,避免只靠 Quartz(任务停了会一直不响) */
private void kickPushDeliveryScan() {
if (remotePushDeliveryService == null) {
return;
}
try {
R<Integer> 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。
@@ -555,6 +741,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);
@@ -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);
});
@@ -285,6 +285,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
result.put("priceDays", priceDays);
result.put("hotelCode", hotelCode);
result.put("markupPercent", priceConfigService.currentMarkupPercent());
result.put("markupFixed", priceConfigService.currentMarkupFixed());
result.put("upstreamTips", upstreamTips);
log.info("|酒店拉取上游|结束|hotelCode={}|room={}|product={}|daily={}|tips={}|", hotelCode, roomCount,
productCount, dailyCount, upstreamTips);
@@ -574,12 +575,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
.last("LIMIT 1"));
if (db != null) {
fake.setRoomTypeCode(StrUtil.blankToDefault(db.getRoomTypeCode(), fake.getRoomTypeCode()));
if (db.getSellPrice() != null && db.getSellPrice().compareTo(BigDecimal.ZERO) > 0) {
price = db.getSellPrice();
}
else if (db.getCostPrice() != null && db.getCostPrice().compareTo(BigDecimal.ZERO) > 0) {
price = db.getCostPrice();
}
fake.setCostPrice(db.getCostPrice());
fake.setSellPrice(db.getSellPrice());
price = outboundSell(db.getCostPrice(), db.getSellPrice(), partner);
}
int n = pushCloseDailyRanges(partner, hotel, List.of(fake), price);
Map<String, Object> data = new LinkedHashMap<>(6);
@@ -1080,7 +1078,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
if (row == null) {
continue;
}
BigDecimal price = row.getSellPrice() != null ? row.getSellPrice() : row.getCostPrice();
BigDecimal price = outboundSell(row.getCostPrice(), row.getSellPrice(), partnerCode);
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
// 无有效价不推,不用 1 元占位
continue;
@@ -1176,7 +1174,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
downstreamClient.hotelSync(partnerCode, hotelId, supplierHotelId, staticEnvelope);
List<Map<String, Object>> roomPriceInfoList = buildHuidingRoomPriceInfoList(hotel, supplierHotelId, products,
dailies);
dailies, partnerCode);
Map<String, Object> dateEnvelope = buildHuidingDatePushEnvelope(ds, hotel, dailyFrom, dailyTo, roomPriceInfoList);
downstreamClient.dailySync(partnerCode, hotelId, supplierHotelId, dateEnvelope);
@@ -1197,10 +1195,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
Map<String, Object> 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()), "-"));
@@ -1265,7 +1260,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
* 按产品聚合日历;慧订 roomTypeCode 全局唯一;ratePlanCode 按早餐建议值。
*/
private List<Map<String, Object>> buildHuidingRoomPriceInfoList(SmHotelEntity hotel, String supplierHotelId,
List<SmHotelProductEntity> products, List<SmHotelDailyEntity> dailies) {
List<SmHotelProductEntity> products, List<SmHotelDailyEntity> dailies, String partnerCode) {
Map<String, SmHotelProductEntity> productByCode = new LinkedHashMap<>();
for (SmHotelProductEntity p : products) {
if (p != null && StrUtil.isNotBlank(p.getProductCode())) {
@@ -1304,10 +1299,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
else {
status = 0;
}
BigDecimal price = d.getSellPrice() != null ? d.getSellPrice() : d.getCostPrice();
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
price = BigDecimal.ONE;
}
BigDecimal price = outboundSell(d.getCostPrice(), d.getSellPrice(), partnerCode);
Map<String, Object> night = new LinkedHashMap<>();
night.put("night", d.getStayDate().toString());
night.put("price", price);
@@ -1458,10 +1450,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
for (SmHotelProductEntity p : products) {
BigDecimal price = defaultPrice;
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
price = p.getSellPrice() != null ? p.getSellPrice() : p.getCostPrice();
}
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
price = BigDecimal.ONE;
price = outboundSell(p.getCostPrice(), p.getSellPrice(), partnerCode);
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("startDate", from.toString());
@@ -1593,13 +1582,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<String, Object> 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 +1605,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);
@@ -1716,8 +1709,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
body.put("apartmentType", p.getApartmentType() == null ? 1 : p.getApartmentType());
body.put("productType", p.getProductType() == null ? 2 : p.getProductType());
SmHotelCancelPolicy.putHuanqiProductCancel(body, resolveUpstreamCancelPolicy(hotel, p));
BigDecimal price = p.getSellPrice() != null ? p.getSellPrice() : p.getCostPrice();
body.put("homePrice", price == null || price.compareTo(BigDecimal.ZERO) <= 0 ? BigDecimal.ONE : price);
body.put("homePrice", outboundSell(p.getCostPrice(), p.getSellPrice(), partnerCode));
body.put("homeNumber", roomQtyMap.getOrDefault(roomCode, 1));
try {
downstreamClient.productSync(partnerCode, hotelId, extHotelId, body);
@@ -1757,7 +1749,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
}
String roomCode = rows.get(0).getRoomTypeCode();
String productCode = e.getKey();
List<Map<String, Object>> ranges = mergeDailyRanges(rows);
List<Map<String, Object>> ranges = mergeDailyRanges(rows, partnerCode);
for (List<Map<String, Object>> batch : CollUtil.split(ranges, 50)) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("extHotelId", extHotelId);
@@ -1782,31 +1774,35 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
/**
* 连续同价/剩余库存/有效房态日期合并为区间(环启 list 最多 50 段)。
* 剩余 = max(0, stock - sold);剩余为 0 时按关房推,避免环启仍按配额超卖。
* 同价按下游动态卖价比较。剩余 = max(0, stock - sold);无剩余按关房推。
*/
private static List<Map<String, Object>> mergeDailyRanges(List<SmHotelDailyEntity> rows) {
private List<Map<String, Object>> mergeDailyRanges(List<SmHotelDailyEntity> rows, String partnerCode) {
List<Map<String, Object>> ranges = new ArrayList<>();
if (CollUtil.isEmpty(rows)) {
return ranges;
}
SmHotelDailyEntity start = rows.get(0);
SmHotelDailyEntity prev = start;
BigDecimal prevPrice = outboundSell(prev.getCostPrice(), prev.getSellPrice(), partnerCode);
for (int i = 1; i < rows.size(); i++) {
SmHotelDailyEntity cur = rows.get(i);
BigDecimal curPrice = outboundSell(cur.getCostPrice(), cur.getSellPrice(), partnerCode);
boolean contiguous = prev.getStayDate() != null && cur.getStayDate() != null
&& prev.getStayDate().plusDays(1).equals(cur.getStayDate());
boolean same = Objects.equals(prev.getSellPrice(), cur.getSellPrice())
boolean same = Objects.equals(prevPrice, curPrice)
&& huanqiRemainStock(prev) == huanqiRemainStock(cur)
&& huanqiDailyStatus(prev) == huanqiDailyStatus(cur);
if (contiguous && same) {
prev = cur;
prevPrice = curPrice;
continue;
}
ranges.add(dailyRangeItem(start, prev));
ranges.add(dailyRangeItem(start, prev, prevPrice));
start = cur;
prev = cur;
prevPrice = curPrice;
}
ranges.add(dailyRangeItem(start, prev));
ranges.add(dailyRangeItem(start, prev, prevPrice));
return ranges;
}
@@ -1829,11 +1825,11 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
return 1;
}
private static Map<String, Object> dailyRangeItem(SmHotelDailyEntity start, SmHotelDailyEntity end) {
private static Map<String, Object> dailyRangeItem(SmHotelDailyEntity start, SmHotelDailyEntity end, BigDecimal price) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("startDate", start.getStayDate().toString());
item.put("endDate", end.getStayDate().toString());
item.put("dailyPrice", end.getSellPrice());
item.put("dailyPrice", price);
int remain = huanqiRemainStock(end);
item.put("dailyNumber", remain);
item.put("dailyStatus", huanqiDailyStatus(end));
@@ -1844,11 +1840,11 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
return o == null ? null : String.valueOf(o);
}
/** 有库存候选里取卖价(无则成本价)最低的一条 */
/** 有库存候选里取协议价(无则卖价)最低的一条 */
private static SmHotelProductEntity inProductLowestPrice(List<SmHotelProductEntity> inStock) {
return inStock.stream().min((a, b) -> {
BigDecimal pa = a.getSellPrice() != null ? a.getSellPrice() : a.getCostPrice();
BigDecimal pb = b.getSellPrice() != null ? b.getSellPrice() : b.getCostPrice();
BigDecimal pa = a.getCostPrice() != null ? a.getCostPrice() : a.getSellPrice();
BigDecimal pb = b.getCostPrice() != null ? b.getCostPrice() : b.getSellPrice();
if (pa == null && pb == null) {
return 0;
}
@@ -1919,19 +1915,27 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
}
SmHotelDetailVO vo = new SmHotelDetailVO();
vo.setHotel(hotel);
// 详情列表不带 rawJson:上游原文很大,管理端 UI 不用,会显著拖慢详情接口
vo.setRooms(roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.select(SmHotelRoomEntity.class, i -> !"rawJson".equals(i.getProperty()))
.eq(SmHotelRoomEntity::getHotelId, hotelId)
.orderByAsc(SmHotelRoomEntity::getRoomTypeCode)));
vo.setProducts(productMapper.selectList(Wrappers.<SmHotelProductEntity>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.<SmHotelDailyEntity>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.<SmHotelDailyEntity>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")));
@@ -1959,8 +1963,10 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
.orderByAsc(SmHotelDailyEntity::getStayDate)
.orderByAsc(SmHotelDailyEntity::getProductCode));
if (result.getRecords() != null) {
List<String> partners = channelConfigService.listEnabledDownstreamPartners();
for (SmHotelDailyEntity row : result.getRecords()) {
fillDailyRemain(row);
fillDailyDynamicPrices(row, partners);
}
}
return R.ok(result);
@@ -1976,6 +1982,43 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
row.setRemainStock(Math.max(0, stock - sold));
}
/** 日历行补默认卖价 + 各下游动态卖价(协议价为底) */
private void fillDailyDynamicPrices(SmHotelDailyEntity row, List<String> partners) {
if (row == null) {
return;
}
BigDecimal base = protocolBase(row.getCostPrice(), row.getSellPrice());
row.setDefaultSellPrice(priceConfigService.resolveSellPrice(base, null));
Map<String, BigDecimal> channelMap = new LinkedHashMap<>();
if (partners != null) {
for (String code : partners) {
if (StrUtil.isBlank(code)) {
continue;
}
channelMap.put(code, priceConfigService.resolveSellPrice(base, code));
}
}
row.setChannelSellPrices(channelMap);
}
/** 协议价优先;无则回落已存卖价 */
private static BigDecimal protocolBase(BigDecimal cost, BigDecimal sell) {
if (cost != null && cost.compareTo(BigDecimal.ZERO) > 0) {
return cost;
}
return sell;
}
/** 下推出站卖价:按 partner 动态加价 */
private BigDecimal outboundSell(BigDecimal cost, BigDecimal sell, String partnerCode) {
BigDecimal base = protocolBase(cost, sell);
BigDecimal resolved = priceConfigService.resolveSellPrice(base, partnerCode);
if (resolved == null || resolved.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ONE;
}
return resolved;
}
@Override
public R<Map<String, Object>> listAuthorizedHotels(String keyword, long current, long size, boolean refresh) {
if (current < 1) {
@@ -2782,14 +2825,15 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
product.setCanCancel(canCancel);
product.setCancelRule(cancelRule);
product.setCostPrice(cost);
product.setSellPrice(priceConfigService.applyMarkup(cost));
// 上游落库只存协议价;默认卖价与渠道卖价推送时动态 resolve,避免写死全局加价
product.setSellPrice(cost);
product.setStock(stock);
product.setRawJson(rate.toJSONString());
productMap.put(productCode, product);
}
else {
product.setCostPrice(cost);
product.setSellPrice(priceConfigService.applyMarkup(cost));
product.setSellPrice(cost);
if (stock > (product.getStock() == null ? 0 : product.getStock())) {
product.setStock(stock);
}
@@ -2809,7 +2853,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
draft.roomTypeCode = roomTypeId;
draft.stayDate = LocalDate.parse(bizDate.substring(0, 10));
draft.costPrice = cost;
draft.sellPrice = priceConfigService.applyMarkup(cost);
draft.sellPrice = cost;
draft.stock = stock;
draft.dailyStatus = stock > 0 ? 1 : 0;
dailyDrafts.add(draft);
@@ -2874,12 +2918,12 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
}
/**
* 日历落库:先按半开区间物理删除再批量插入。
* 日历落库:窗内 upsert(有则改、无则批量插);上游未带回的日期关房+库存 0,不做整窗物理删。
* <p>
* 唯一键 uk_product_date=(tenant_id, product_id, stay_date) 不含 del_flag,必须物理删。
* 删除区间须覆盖本次将写入的日期(含回调报文里的历史日),不能写死 LocalDate.now()。
* 唯一键 uk_product_date=(tenant_id, product_id, stay_date)。更新时保留 sold。
* 过期(今天往前 30 天)顺带物理清理,避免关房行无限堆积。
* <p>
* 全量详情多酒店并行时,同租户唯一索引上易出现死锁,失败自动重试几次。
* 全量/近窗多酒店并行时仍可能死锁,失败自动重试几次。
*/
private int persistDailies(SmHotelEntity hotel, List<DailyDraft> drafts, LocalDate fromInclusive,
LocalDate toExclusive) {
@@ -2907,15 +2951,14 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
}
}
}
return 0;
}
return 0;
}
private int persistDailiesOnce(SmHotelEntity hotel, List<DailyDraft> 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 +2974,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<String, Long> productIdMap = new HashMap<>();
List<SmHotelProductEntity> products = productMapper
.selectList(Wrappers.<SmHotelProductEntity>lambdaQuery()
@@ -2957,18 +3001,45 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
for (SmHotelProductEntity p : products) {
productIdMap.put(p.getProductCode(), p.getId());
}
Map<String, DailyDraft> unique = new LinkedHashMap<>();
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<SmHotelDailyEntity> existRows = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getHotelId, hotel.getId())
.ge(SmHotelDailyEntity::getStayDate, winFrom)
.lt(SmHotelDailyEntity::getStayDate, winToEx));
Map<String, SmHotelDailyEntity> 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<SmHotelDailyEntity> toInsert = new ArrayList<>(unique.size());
List<SmHotelDailyEntity> toInsert = new ArrayList<>();
List<SmHotelDailyEntity> toUpdate = new ArrayList<>();
for (DailyDraft d : unique.values()) {
Long productId = productIdMap.get(d.productCode);
if (productId == null) {
continue;
}
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);
@@ -2978,13 +3049,43 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
daily.setCostPrice(d.costPrice);
daily.setSellPrice(d.sellPrice);
daily.setStock(d.stock);
daily.setSold(0);
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 死锁 */
@@ -3172,42 +3273,56 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
}
/**
* 从位置区域文案取「市级」层名称并去掉「市」。
* <p>
* 规则:先按省/市/州/区县拆层;优先取以「市」结尾的那一层(县级市如恩施市也算);
* 若只有区县(如城阳区),则退回上一层地级市(青岛市)。
* </p>
* 环启 cityName:
* <ol>
* <li>优先从「位置区域」district 取带「市」的那一层(天津市津南区 → 天津市)</li>
* <li>没有再从其它字段(如 remark 的 city=、调用方传入值)取</li>
* <li>仍取不到带「市」的名称则返回空,不编造</li>
* </ol>
*/
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 "";
}
/**
* 从文案中只取以「市」结尾的一层;没有则返回空。
* <ul>
* <li>湖北省恩施土家族苗族自治州恩施市 → 恩施</li>
* <li>山东省青岛市城阳区 → 青岛</li>
* <li>北京市朝阳区 → 北京</li>
* <li>天津市津南区 → 天津市</li>
* <li>山东省青岛市城阳区 → 青岛市</li>
* <li>湖北省恩施土家族苗族自治州恩施市 → 恩施市</li>
* <li>津南区 / 天津城区 → 空(没有「xx市」就不取)</li>
* </ul>
*/
static String extractHuanqiCityNameFromDistrict(String district) {
if (StrUtil.isBlank(district)) {
return "";
}
String s = district.trim().replace(" ", "");
String s = district.trim().replace(" ", "").replace("/", "");
List<String> 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 "";
}
/** 湖北省恩施土家族苗族自治州恩施市 → [湖北省, 恩施土家族苗族自治州, 恩施市] */
@@ -3223,19 +3338,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;
@@ -20,6 +20,7 @@ import com.bsd.pjcloud.supermarket.mapper.SmHotelRoomMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import com.bsd.pjcloud.supermarket.service.SmHotelOpenApiService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.supermarket.util.HotelAreaAdcodeUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -73,6 +74,8 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
private final SmHotelOpenProperties openProperties;
private final SmHotelPriceConfigService priceConfigService;
@Override
public R<Map<String, Object>> hotelList(SmHotelDownstreamAppEntity app, Map<String, Object> body) {
R<List<Long>> scopeR = requireAllowedHotelIds(app);
@@ -274,6 +277,7 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
List<SmHotelProductEntity> products = productMapper.selectList(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotel.getId())
.orderByAsc(SmHotelProductEntity::getProductCode));
String partner = resolvePartnerCode(app);
List<Map<String, Object>> productList = new ArrayList<>();
for (SmHotelProductEntity p : products) {
Map<String, Object> row = new LinkedHashMap<>(8);
@@ -283,7 +287,9 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
row.put("priceCode", p.getPriceCode());
row.put("mealType", p.getMealType());
row.put("mealCount", p.getMealCount());
row.put("sellPrice", p.getSellPrice());
BigDecimal base = p.getCostPrice() != null ? p.getCostPrice() : p.getSellPrice();
row.put("costPrice", p.getCostPrice());
row.put("sellPrice", priceConfigService.resolveSellPrice(base, partner));
row.put("stock", p.getStock() == null ? 0 : p.getStock());
productList.add(row);
}
@@ -321,13 +327,15 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
.orderByAsc(SmHotelDailyEntity::getProductCode)
.orderByAsc(SmHotelDailyEntity::getStayDate)
.last("limit 5000"));
String partner = resolvePartnerCode(app);
List<Map<String, Object>> list = new ArrayList<>();
for (SmHotelDailyEntity d : dailies) {
Map<String, Object> row = new LinkedHashMap<>(10);
row.put("productCode", d.getProductCode());
row.put("stayDate", d.getStayDate() == null ? null : d.getStayDate().toString());
row.put("sellPrice", d.getSellPrice());
BigDecimal base = d.getCostPrice() != null ? d.getCostPrice() : d.getSellPrice();
row.put("costPrice", d.getCostPrice());
row.put("sellPrice", priceConfigService.resolveSellPrice(base, partner));
row.put("stock", d.getStock());
row.put("sold", d.getSold());
// remainStock 非表字段,开放接口按 stock-sold 计算,避免下游永远拿不到剩余
@@ -900,9 +900,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (body.get("guestList") != null) {
order.setGuestJson(JSONUtil.toJsonStr(body.get("guestList")));
}
// 全局加价仅针对第三方来源;本地酒店卖价按日历/产品原价,markup=0
// 加价快照:本地=0;下游按 partner 渠道规则;无 partner 用全局
boolean localHotel = SmHotelDataSource.isLocal(hotel.getSheetName());
order.setMarkupPercent(localHotel ? BigDecimal.ZERO : priceConfigService.currentMarkupPercent());
applyOrderMarkupSnapshot(order, localHotel, order.getPartnerCode());
order.setSellAmount(toDecimal(body.get("totalAmount")));
if (product != null && product.getCostPrice() != null && order.getCount() != null) {
order.setCostAmount(product.getCostPrice().multiply(BigDecimal.valueOf(order.getCount())));
@@ -1175,13 +1175,28 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
int stock = row.getStock() == null ? 0 : row.getStock();
int sold = row.getSold() == null ? 0 : row.getSold();
int remain = Math.max(0, stock - sold);
BigDecimal sell = row.getSellPrice() != null ? row.getSellPrice()
: (product.getSellPrice() != null ? product.getSellPrice() : BigDecimal.ZERO);
// 本地酒店:用落库卖价;第三方:按 partner 对协议价动态加价(无配置=原价)
BigDecimal sell;
if (localDs) {
sell = row.getSellPrice() != null ? row.getSellPrice()
: (product.getSellPrice() != null ? product.getSellPrice() : BigDecimal.ZERO);
}
else {
BigDecimal base = row.getCostPrice() != null ? row.getCostPrice()
: (row.getSellPrice() != null ? row.getSellPrice()
: (product.getCostPrice() != null ? product.getCostPrice()
: product.getSellPrice()));
sell = priceConfigService.resolveSellPrice(base, partnerCode);
if (sell == null) {
sell = BigDecimal.ZERO;
}
}
localSell = localSell.add(sell.multiply(BigDecimal.valueOf(roomCnt)));
one.put("exists", true);
one.put("dailyStatus", row.getDailyStatus());
one.put("remain", remain);
one.put("sellPrice", sell);
one.put("costPrice", row.getCostPrice());
}
days.add(one);
}
@@ -1407,8 +1422,17 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
order.setGuestJson(JSONUtil.toJsonStr(List.of(guest)));
}
boolean localHotel = SmHotelDataSource.isLocal(hotel.getSheetName());
order.setMarkupPercent(localHotel ? BigDecimal.ZERO : priceConfigService.currentMarkupPercent());
if (product.getSellPrice() != null) {
applyOrderMarkupSnapshot(order, localHotel, order.getPartnerCode());
if (!localHotel) {
BigDecimal sum = sumResolvedSellAmount(product.getId(), checkIn, checkOut, count, order.getPartnerCode());
if (sum != null) {
order.setSellAmount(sum);
}
else if (body.get("totalAmount") != null) {
order.setSellAmount(toDecimal(body.get("totalAmount")));
}
}
else if (product.getSellPrice() != null) {
order.setSellAmount(product.getSellPrice().multiply(BigDecimal.valueOf(count)));
}
else if (body.get("totalAmount") != null) {
@@ -1624,10 +1648,17 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
guest.put("mobile", contactPhone);
order.setGuestJson(JSONUtil.toJsonStr(List.of(guest)));
}
order.setMarkupPercent(localHotel ? BigDecimal.ZERO : priceConfigService.currentMarkupPercent());
applyOrderMarkupSnapshot(order, localHotel, null);
if (product.getCostPrice() != null) {
order.setCostAmount(product.getCostPrice().multiply(BigDecimal.valueOf(count)));
}
// App 自营:按全局加价动态卖价合计
if (!localHotel && order.getSellAmount() == null) {
BigDecimal sum = sumResolvedSellAmount(product.getId(), checkIn, checkOut, count, null);
if (sum != null) {
order.setSellAmount(sum);
}
}
order.setOrderStatus("CREATED");
order.setPayStatus(PAY_UNPAID);
order.setCompensateStatus(COMP_NONE);
@@ -1653,15 +1684,21 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
}
else {
// 上游:优先用前端合计(含间夜),否则按产品单价×间数×间夜
// 上游:优先前端合计;否则按日历协议价+全局加价动态合计
if (body.get("totalAmount") != null) {
order.setSellAmount(toDecimal(body.get("totalAmount")));
}
else if (product.getSellPrice() != null) {
int nights = Math.max(1, SmHotelStayDates.occupyUnits(checkIn, checkOut));
order.setSellAmount(product.getSellPrice()
.multiply(BigDecimal.valueOf(count))
.multiply(BigDecimal.valueOf(nights)));
else {
BigDecimal sum = sumResolvedSellAmount(product.getId(), checkIn, checkOut, count, null);
if (sum != null) {
order.setSellAmount(sum);
}
else if (product.getSellPrice() != null) {
int nights = Math.max(1, SmHotelStayDates.occupyUnits(checkIn, checkOut));
order.setSellAmount(product.getSellPrice()
.multiply(BigDecimal.valueOf(count))
.multiply(BigDecimal.valueOf(nights)));
}
}
}
if (order.getSellAmount() == null || order.getSellAmount().compareTo(BigDecimal.ZERO) <= 0) {
@@ -2624,6 +2661,45 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return any ? sum : null;
}
/** 按 partner 对日历协议价动态加价后合计(App partner=null 用全局) */
private BigDecimal sumResolvedSellAmount(Long productId, LocalDate checkIn, LocalDate checkOut, int rooms,
String partnerCode) {
BigDecimal sum = BigDecimal.ZERO;
boolean any = false;
for (LocalDate d : SmHotelStayDates.occupyDates(checkIn, checkOut)) {
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getProductId, productId)
.eq(SmHotelDailyEntity::getStayDate, d)
.last("limit 1"));
if (row == null) {
continue;
}
BigDecimal base = row.getCostPrice() != null ? row.getCostPrice() : row.getSellPrice();
BigDecimal sell = priceConfigService.resolveSellPrice(base, partnerCode);
if (sell != null) {
sum = sum.add(sell.multiply(BigDecimal.valueOf(rooms)));
any = true;
}
}
return any ? sum : null;
}
private void applyOrderMarkupSnapshot(SmHotelOrderEntity order, boolean localHotel, String partnerCode) {
if (order == null) {
return;
}
if (localHotel) {
order.setMarkupPercent(BigDecimal.ZERO);
order.setMarkupFixed(BigDecimal.ZERO);
order.setPricePartnerCode(null);
return;
}
SmHotelPriceConfigService.MarkupRule rule = priceConfigService.resolveMarkupRule(partnerCode);
order.setMarkupPercent(rule.percent());
order.setMarkupFixed(rule.fixed());
order.setPricePartnerCode(StrUtil.isBlank(partnerCode) ? null : StrUtil.trim(partnerCode));
}
/**
* @return null=通过;非空=失败原因
*/
@@ -1,13 +1,17 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceChannelConfigEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelPriceChannelConfigMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelPriceConfigMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.common.core.util.R;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -15,7 +19,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@@ -24,16 +30,22 @@ import java.util.Map;
public class SmHotelPriceConfigServiceImpl extends ServiceImpl<SmHotelPriceConfigMapper, SmHotelPriceConfigEntity>
implements SmHotelPriceConfigService {
/** 折扣下限:不能 ≤ -100%,否则卖价为 0 或负 */
private static final BigDecimal MIN_PERCENT = new BigDecimal("-99.99");
/** 加价上限,防止误填过大 */
private static final BigDecimal MAX_PERCENT = new BigDecimal("500");
private static final BigDecimal MIN_SELL = new BigDecimal("0.01");
private static final String ENABLED = "1";
private final SmHotelProductMapper productMapper;
private final SmHotelDailyMapper dailyMapper;
private final SmHotelPriceChannelConfigMapper channelConfigMapper;
private final SmHotelChannelConfigService channelConfigService;
@Override
public R<SmHotelPriceConfigEntity> getCurrent() {
SmHotelPriceConfigEntity one = baseMapper.selectOne(Wrappers.<SmHotelPriceConfigEntity>lambdaQuery()
@@ -42,6 +54,10 @@ public class SmHotelPriceConfigServiceImpl extends ServiceImpl<SmHotelPriceConfi
if (one == null) {
one = new SmHotelPriceConfigEntity();
one.setMarkupPercent(BigDecimal.ZERO);
one.setMarkupFixed(BigDecimal.ZERO);
}
else if (one.getMarkupFixed() == null) {
one.setMarkupFixed(BigDecimal.ZERO);
}
return R.ok(one);
}
@@ -52,19 +68,19 @@ public class SmHotelPriceConfigServiceImpl extends ServiceImpl<SmHotelPriceConfi
if (entity.getMarkupPercent() == null) {
return R.failed("调整百分比不能为空");
}
BigDecimal p = entity.getMarkupPercent();
if (p.compareTo(MIN_PERCENT) < 0) {
return R.failed("折扣不能超过 99.99%(调整值须大于 -100)");
}
if (p.compareTo(MAX_PERCENT) > 0) {
return R.failed("加价百分比不能超过 500%");
String err = validatePercent(entity.getMarkupPercent());
if (err != null) {
return R.failed(err);
}
BigDecimal fixed = entity.getMarkupFixed() == null ? BigDecimal.ZERO : entity.getMarkupFixed();
SmHotelPriceConfigEntity exist = baseMapper.selectOne(Wrappers.<SmHotelPriceConfigEntity>lambdaQuery()
.last("limit 1"));
if (exist == null) {
entity.setMarkupFixed(fixed);
return R.ok(save(entity));
}
exist.setMarkupPercent(p);
exist.setMarkupPercent(entity.getMarkupPercent());
exist.setMarkupFixed(fixed);
exist.setRemark(entity.getRemark());
return R.ok(updateById(exist));
}
@@ -76,43 +92,175 @@ public class SmHotelPriceConfigServiceImpl extends ServiceImpl<SmHotelPriceConfi
return one == null || one.getMarkupPercent() == null ? BigDecimal.ZERO : one.getMarkupPercent();
}
@Override
public BigDecimal currentMarkupFixed() {
SmHotelPriceConfigEntity one = baseMapper.selectOne(Wrappers.<SmHotelPriceConfigEntity>lambdaQuery()
.last("limit 1"));
return one == null || one.getMarkupFixed() == null ? BigDecimal.ZERO : one.getMarkupFixed();
}
@Override
public BigDecimal applyMarkup(BigDecimal costPrice) {
return applyMarkup(costPrice, currentMarkupPercent());
return applyMarkup(costPrice, currentMarkupPercent(), currentMarkupFixed());
}
@Override
public BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent) {
return applyMarkup(costPrice, percent, BigDecimal.ZERO);
}
@Override
public BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent, BigDecimal fixed) {
if (costPrice == null) {
return null;
}
BigDecimal p = percent == null ? BigDecimal.ZERO : percent;
BigDecimal f = fixed == null ? BigDecimal.ZERO : fixed;
BigDecimal rate = BigDecimal.ONE.add(p.divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP));
if (rate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
BigDecimal sell = costPrice.multiply(rate).add(f).setScale(2, RoundingMode.HALF_UP);
if (sell.compareTo(MIN_SELL) < 0) {
return MIN_SELL;
}
return costPrice.multiply(rate).setScale(2, RoundingMode.HALF_UP);
return sell;
}
@Override
public BigDecimal resolveSellPrice(BigDecimal basePrice, String partnerCode) {
if (basePrice == null) {
return null;
}
MarkupRule rule = resolveMarkupRule(partnerCode);
return applyMarkup(basePrice, rule.percent(), rule.fixed());
}
@Override
public MarkupRule resolveMarkupRule(String partnerCode) {
String code = StrUtil.trim(partnerCode);
if (StrUtil.isNotBlank(code)) {
SmHotelPriceChannelConfigEntity ch = channelConfigMapper.selectOne(
Wrappers.<SmHotelPriceChannelConfigEntity>lambdaQuery()
.eq(SmHotelPriceChannelConfigEntity::getPartnerCode, code)
.eq(SmHotelPriceChannelConfigEntity::getEnabled, ENABLED)
.last("limit 1"));
if (ch != null) {
return new MarkupRule(
ch.getMarkupPercent() == null ? BigDecimal.ZERO : ch.getMarkupPercent(),
ch.getMarkupFixed() == null ? BigDecimal.ZERO : ch.getMarkupFixed(),
true,
code);
}
// 有 partner 但未配置渠道加价 → 原价(0/0)
return new MarkupRule(BigDecimal.ZERO, BigDecimal.ZERO, false, code);
}
return new MarkupRule(currentMarkupPercent(), currentMarkupFixed(), false, null);
}
@Override
public R<List<SmHotelPriceChannelConfigEntity>> listChannelConfigs() {
List<SmHotelPriceChannelConfigEntity> list = channelConfigMapper.selectList(
Wrappers.<SmHotelPriceChannelConfigEntity>lambdaQuery()
.orderByAsc(SmHotelPriceChannelConfigEntity::getPartnerCode));
return R.ok(list == null ? List.of() : list);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> saveChannelConfig(SmHotelPriceChannelConfigEntity entity) {
if (entity == null || StrUtil.isBlank(entity.getPartnerCode())) {
return R.failed("下游伙伴编码不能为空");
}
String partner = StrUtil.trim(entity.getPartnerCode());
List<String> enabledPartners = channelConfigService.listEnabledDownstreamPartners();
if (enabledPartners == null || !enabledPartners.contains(partner)) {
return R.failed("伙伴「" + partner + "」不在已启用下游列表中,请先在渠道配置启用");
}
BigDecimal percent = entity.getMarkupPercent() == null ? BigDecimal.ZERO : entity.getMarkupPercent();
String err = validatePercent(percent);
if (err != null) {
return R.failed(err);
}
BigDecimal fixed = entity.getMarkupFixed() == null ? BigDecimal.ZERO : entity.getMarkupFixed();
String enabled = StrUtil.blankToDefault(entity.getEnabled(), ENABLED);
SmHotelPriceChannelConfigEntity exist = channelConfigMapper.selectOne(
Wrappers.<SmHotelPriceChannelConfigEntity>lambdaQuery()
.eq(SmHotelPriceChannelConfigEntity::getPartnerCode, partner)
.last("limit 1"));
if (exist == null) {
entity.setPartnerCode(partner);
entity.setMarkupPercent(percent);
entity.setMarkupFixed(fixed);
entity.setEnabled(enabled);
return R.ok(channelConfigMapper.insert(entity) == 1);
}
exist.setMarkupPercent(percent);
exist.setMarkupFixed(fixed);
exist.setEnabled(enabled);
exist.setRemark(entity.getRemark());
return R.ok(channelConfigMapper.updateById(exist) == 1);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> deleteChannelConfig(Long id) {
if (id == null) {
return R.failed("id 为空");
}
return R.ok(channelConfigMapper.deleteById(id) > 0);
}
@Override
public R<Map<String, Object>> previewSellPrices(BigDecimal basePrice) {
BigDecimal base = basePrice == null ? new BigDecimal("100") : basePrice;
Map<String, Object> out = new LinkedHashMap<>();
out.put("basePrice", base);
out.put("defaultSellPrice", applyMarkup(base));
out.put("globalPercent", currentMarkupPercent());
out.put("globalFixed", currentMarkupFixed());
List<Map<String, Object>> channels = new ArrayList<>();
List<String> partners = channelConfigService.listEnabledDownstreamPartners();
if (partners != null) {
for (String code : partners) {
MarkupRule rule = resolveMarkupRule(code);
Map<String, Object> row = new LinkedHashMap<>();
row.put("partnerCode", code);
row.put("configured", rule.fromChannel());
row.put("markupPercent", rule.percent());
row.put("markupFixed", rule.fixed());
row.put("sellPrice", resolveSellPrice(base, code));
channels.add(row);
}
}
out.put("channels", channels);
return R.ok(out);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Map<String, Object>> resyncAllSellPrices() {
BigDecimal percent = currentMarkupPercent();
BigDecimal fixed = currentMarkupFixed();
BigDecimal rate = BigDecimal.ONE.add(percent.divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP));
if (rate.compareTo(BigDecimal.ZERO) <= 0) {
return R.failed("当前调整比例会导致卖价≤0,请先修正配置");
}
int productUpdated = productMapper.resyncSellPriceByCost(rate);
int dailyUpdated = dailyMapper.resyncSellPriceByCost(rate);
// 仅同步非本地(第三方)酒店;本地日历/产品卖价由手工维护,不受全局加价影响
log.info("|售价全局同步(排除本地)|percent={}|rate={}|productUpdated={}|dailyUpdated={}|", percent, rate,
productUpdated, dailyUpdated);
int productUpdated = productMapper.resyncSellPriceByCost(rate, fixed);
int dailyUpdated = dailyMapper.resyncSellPriceByCost(rate, fixed);
log.info("|售价全局同步(排除本地)|percent={}|fixed={}|rate={}|productUpdated={}|dailyUpdated={}|",
percent, fixed, rate, productUpdated, dailyUpdated);
Map<String, Object> out = new LinkedHashMap<>();
out.put("markupPercent", percent);
out.put("markupFixed", fixed);
out.put("rate", rate);
out.put("productUpdated", productUpdated);
out.put("dailyUpdated", dailyUpdated);
out.put("scope", "non_local");
out.put("scope", "non_local_default_sell");
return R.ok(out);
}
private static String validatePercent(BigDecimal p) {
if (p.compareTo(MIN_PERCENT) < 0) {
return "折扣不能超过 99.99%(调整值须大于 -100)";
}
if (p.compareTo(MAX_PERCENT) > 0) {
return "加价百分比不能超过 500%";
}
return null;
}
}
@@ -76,8 +76,29 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
@Override
public Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page,
LambdaQueryWrapper<SmHotelEntity> wrapper) {
applyListSelectAndScope(wrapper);
return baseMapper.selectPage(page, wrapper);
}
@Override
public Page<SmHotelEntity> pageScopeWithoutCount(Page<SmHotelEntity> page,
LambdaQueryWrapper<SmHotelEntity> wrapper) {
applyListSelectAndScope(wrapper);
long size = Math.max(1L, Math.min(page.getSize(), 100L));
long current = Math.max(1L, page.getCurrent());
Page<SmHotelEntity> q = new Page<>(current, size + 1, false);
Page<SmHotelEntity> raw = baseMapper.selectPage(q, wrapper);
java.util.List<SmHotelEntity> rows = raw.getRecords() == null ? java.util.List.of() : raw.getRecords();
boolean hasMore = rows.size() > size;
java.util.List<SmHotelEntity> slice = hasMore ? rows.subList(0, (int) size) : rows;
long total = (current - 1) * size + slice.size() + (hasMore ? 1 : 0);
Page<SmHotelEntity> out = new Page<>(current, size, total);
out.setRecords(new java.util.ArrayList<>(slice));
return out;
}
private void applyListSelectAndScope(LambdaQueryWrapper<SmHotelEntity> 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<SmHotelMapper, SmHotelEntity
SmHotelEntity::getPhone, SmHotelEntity::getCoverImage,
SmHotelEntity::getCreateTime, SmHotelEntity::getUpdateTime,
SmHotelEntity::getDelFlag);
// App/管家:按酒店商户绑定部门隔离;无酒店商户则无数据
hotelMerchantScopeHelper.applyDeptScope(wrapper);
return baseMapper.selectPage(page, wrapper);
}
@Override
@@ -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<String, Object> 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<Boolean> 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());
}
@@ -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 + 滚价;手动/定时均写同步日志并支持进度查询。
@@ -97,6 +99,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 +514,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
logEntity.setSkipCount(droppedNoEid);
}
syncLogMapper.insert(logEntity);
saveResumeHotelIds(logEntity.getId(), hotelIds);
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
@@ -555,10 +564,10 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
}
}
else {
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, syncType)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
if (running != null && running > 0) {
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, syncType)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
if (running != null && running > 0) {
return R.failed("已有同类型任务进行中,请稍后再试(若卡住请先暂停/终止)");
}
}
@@ -596,6 +605,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 +770,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<Long> hotelIds = channelDeptService.listHotelIdsForDownstreamPartner(partner);
hotelIds = filterShengnuoHotelsRequireEid(partner, hotelIds);
// 优先用开跑时快照(RETRY 只有失败子集);没有快照再回退渠道全量
List<Long> 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 +794,19 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
final List<Long> ids = List.copyOf(hotelIds == null ? List.of() : hotelIds);
final int baseIdx = resumeIdx;
final List<String> 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)) {
@@ -1135,7 +1160,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
+ remain + "(每批 " + batchSize + ")");
R<Map<String, Object>> 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) {
@@ -1412,7 +1437,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 +1450,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 +1863,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<Long> 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<Long> 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<Long> 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。
* 本地及其它渠道酒店一律不进此任务。
@@ -1962,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) {
@@ -2816,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");
@@ -2902,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<String, Object> 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<String, Object> 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<String, Object> result = new LinkedHashMap<>();
@@ -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) {
@@ -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;
@@ -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 {
/**
* 内部幂等接收酒店管理推送(不进入商户策略/关系订阅矩阵;独立于有房通知)。
* <p>收件箱写入成功但系统通知被跳过(无设备/用户关推送)时返回失败文案,避免上游误记为「手机已送达」。
*
* @param dto 超市酒店管理推送事件快照
* @return true表示消息中心已完整接收
* @return true 表示入箱且系统通知已进入投递;失败时 msg 说明跳过原因
*/
@Inner
@PostMapping("/inner/hotel-manager-push-event")
public R<Boolean> 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);
}
/**
@@ -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 消息系统通知本地兜底扫描。
* <p>正式调度仍以 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);
}
}
}
@@ -11,7 +11,7 @@ public interface ThirdHotelManagerNotifyService {
* 幂等接收酒店管理推送事件。
*
* @param dto 超市模块提交的事件快照
* @return true 表示已入箱或此前已幂等完成
* @return null 表示入箱且系统通知已进入待投递/已幂等;非空为跳过系统通知的中文原因(收件箱通常已写入)
*/
boolean receive(HotelManagerPushEventDto dto);
String receive(HotelManagerPushEventDto dto);
}
@@ -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);
/**
* 分页查询指定商户的业务消息投递记录。
*
@@ -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) {
@@ -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.<ThirdPushDeliveryEntity>lambdaQuery()
.eq(ThirdPushDeliveryEntity::getMessageId, message.getId())) > 0) {
return;
public String createDelivery(ThirdAppMessageEntity message, String skipReason) {
ThirdPushDeliveryEntity existing = deliveryMapper.selectOne(Wrappers.<ThirdPushDeliveryEntity>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.<ThirdPushDeliveryEntity>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.<ThirdPushDeliveryEntity>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.<ThirdPushDeliveryEntity>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.<ThirdPushDeliveryEntity>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.<ThirdPushDeliveryEntity>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.<ThirdPushDeliveryEntity>lambdaUpdate()
.eq(ThirdPushDeliveryEntity::getId, current.getId())
.eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PROCESSING));
}
/**
@@ -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;
}
+7
View File
@@ -245,6 +245,13 @@
<version>5.7.0</version>
<scope>compile</scope>
</dependency>
<!-- 酒店分销邀请码:注册页复用同一邀请码入口 -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-supermarket-api</artifactId>
<version>5.7.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
@@ -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 ServiceImpl<SysDeptInviteMapper, S
private final SysRoleService sysRoleService;
private final SysUserIdentityService sysUserIdentityService;
private final RemoteMchService remoteMchService;
private final RemoteSmHotelFxInviteService remoteSmHotelFxInviteService;
private final ApplicationEventPublisher applicationEventPublisher;
private final FlowUtil flowUtil;
private final SysMessageService sysMessageService;
@@ -261,28 +263,12 @@ public class SysDeptInviteServiceImpl extends ServiceImpl<SysDeptInviteMapper, S
@Override
public SysDeptInvitePreviewVO preview(String token) {
SysDeptInviteEntity invite = requireValidInvite(token);
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.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;
String code = StrUtil.trim(token);
// 分销邀请码统一 FX 前缀,与部门 6/8 位数字、UUID 邀请码格式互斥,按格式分流
if (isFxInviteCode(code)) {
return buildFxPreview(requireFxInvite(code));
}
return buildDeptPreview(requireValidDeptInvite(code));
}
@Override
@@ -302,17 +288,168 @@ public class SysDeptInviteServiceImpl extends ServiceImpl<SysDeptInviteMapper, S
return R.failed("验证码错误或已过期");
}
SysDeptInviteEntity invite = requireValidInvite(dto.getToken());
boolean phoneExists = sysUserMapper.exists(Wrappers.<SysUser>lambdaQuery().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<SmHotelFxInvitePreviewVO> 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<Boolean> 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<Boolean> 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<Boolean> 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.<SysUser>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.<SysUser>lambdaQuery().eq(SysUser::getUsername, username));
@@ -325,77 +462,87 @@ public class SysDeptInviteServiceImpl extends ServiceImpl<SysDeptInviteMapper, S
if (StrUtil.isNotBlank(dto.getPassword())) {
String passwordErr = validateInvitePassword(password);
if (passwordErr != null) {
return R.failed(passwordErr);
prepared.errorMsg = passwordErr;
return prepared;
}
}
String displayName = StrUtil.blankToDefault(StrUtil.trim(dto.getName()),
"用户" + phone.substring(Math.max(0, phone.length() - 4)));
String nameErr = validateInviteName(displayName);
if (nameErr != null) {
if (validateInviteName(displayName) != null) {
displayName = "用户" + phone.substring(Math.max(0, phone.length() - 4));
}
String idCard = StrUtil.trim(dto.getIdCard());
if (StrUtil.isNotBlank(idCard) && !ID_CARD_PATTERN.matcher(idCard).matches()) {
return R.failed("身份证号格式不正确");
prepared.errorMsg = "身份证号格式不正确";
return prepared;
}
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());
prepared.username = username;
prepared.password = password;
prepared.displayName = displayName;
prepared.idCard = StrUtil.blankToDefault(idCard, null);
prepared.gender = dto.getGender() == null ? 0 : dto.getGender();
return prepared;
}
private SysUser insertInviteUser(PreparedInviteUser prepared, Long deptId, boolean needAudit, String phone) {
SysUser sysUser = new SysUser();
sysUser.setUsername(username);
sysUser.setUsername(prepared.username);
sysUser.setPhone(phone);
sysUser.setIdCard(StrUtil.blankToDefault(idCard, null));
sysUser.setName(PersonNameUtils.normalize(displayName));
sysUser.setIdCard(prepared.idCard);
sysUser.setName(PersonNameUtils.normalize(prepared.displayName));
sysUser.setNickname(sysUser.getName());
sysUser.setGender(dto.getGender() == null ? 0 : dto.getGender());
sysUser.setDeptId(invite.getDeptId());
sysUser.setGender(prepared.gender);
sysUser.setDeptId(deptId);
sysUser.setLockFlag(UserStateEnum.NORMAL.getCode());
sysUser.setCreateBy(username);
sysUser.setUpdateBy(username);
sysUser.setPassword(ENCODER.encode(password));
sysUser.setCreateBy(prepared.username);
sysUser.setUpdateBy(prepared.username);
sysUser.setPassword(ENCODER.encode(prepared.password));
sysUser.setPasswordModifyTime(LocalDateTime.now());
sysUser.setProcessStatus(needAudit ? TaskCheckStatusEnum.PADDING.getCode() : TaskCheckStatusEnum.RESULT.getCode());
sysUser.setProcessTime(LocalDateTime.now());
sysUser.setStaffingType(UserStaffingTypeEnum.NONE_STAFFING.getType());
sysUser.setPasswordExpireFlag(UserExpireEnum.NORMAL.getCode());
sysUserMapper.insert(sysUser);
return sysUser;
}
// 与后台新增用户一致:固定 GENERAL_USER + WALLET_AUTO_CREATE(按角色编码,避免参数配置漂移)
List<String> roleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET);
private void assignDefaultRolesAndIdentity(SysUser sysUser, String identityType, String... extraRoleCodes) {
List<String> 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<SysRole> defaultRoles = Optional.ofNullable(sysRoleService.findRolesByCodes(roleCodes))
.orElseGet(Collections::emptyList);
Map<String, Long> 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<SysDeptInviteMapper, S
return null;
}
private SysDeptInviteEntity requireValidInvite(String token) {
private SysDeptInviteEntity requireValidDeptInvite(String token) {
if (StrUtil.isBlank(token)) {
throw new IllegalArgumentException("邀请码无效");
}