This commit is contained in:
dzc committed 2026-10-08 11:48:27 +08:00
commit 675d5552d4
89 files changed
+8707 -1166

No files matched your search

@@ -1,254 +1,47 @@
/*
* Copyright (c) 2018-2025, lengleng All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* Neither the name of the pig4cloud.com developer nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
* Author: lengleng (wangiegie@gmail.com)
*/
package com.bsd.pjcloud.app.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.dto.UserTokenDTO;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteTokenService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.app.service.AppUserService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.canteen.api.feign.RemoteOnlineCanteenSpuService;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.CanteenOnlineSpuAppVO;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdPartyAuthService;
import com.bsd.pjcloud.thirdparty.dto.SHJNSSODTO;
import com.bsd.pjcloud.thirdparty.dto.ZZDUserDTO;
import com.bsd.pjcloud.thirdparty.entity.ThirdPartyUserEntity;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.apache.seata.spring.annotation.GlobalTransactional;
import org.springframework.http.HttpHeaders;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* app用户表
*
* @author zjf
* @date 2022-12-07 09:52:03
* App 辅助调试接口(原浙政钉/诗画单点登录已移除)。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/appLogin")
@Tag(description = "appLogin", name = "浙政钉诗画单点登录")
@Tag(description = "appLogin", name = "App登录辅助")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class AppUserAutoLoginController {
// private final AppUserService appUserService;
private final RemoteTokenService remoteTokenService;
private final RemoteUserService remoteUserService;
private final RemoterThirdPartyAuthService remoterThirdPartyAuthService;
private final RemoteCanteenSpuService remoteCanteenSpuService;
private final RemoteOnlineCanteenSpuService remoteOnlineCanteenSpuService;
private final RemoteDailyMenuService remoteDailyMenuService;
/*
*
* @param token 浙政钉前端获取的token
* @return
*/
@GetMapping("/zzdsso/{token}")
@Operation(summary = "浙政钉单点登录")
@Inner(value = false)
@GlobalTransactional
@Transactional(rollbackFor = Exception.class)
//todo 引入seata分布式事务
public R zzdsso(@PathVariable(name = "token") String token) {
//获取数字政务token通过浙政钉前端获取的token,调试阶段先写死
String json = "{" +
"\"username\": \"李宗相\"," +
"\"organizationId\": 2," +
"\"userId\": 1340," +
"\"nickName\": \"李宗相\"," +
"\"mobile\": \"18576496089\"," +
"\"regionCode\": null," +
"\"ext\": {" +
"\"employeeCode\": \"GE_ae12bdf21598498eb48bf2cf2c45a3e1\"," +
"\"organizationCode\": \"GO_63e77077d5e041789bbbe1d0d20bcf41\"," +
"\"nickNameCn\": \"李宗相\"," +
"\"openid\": \"1b51f0e11fc73b8adddcbaf7982daab6\"," +
"\"realmId\": \"196729\"," +
"\"mobile\": null" +
"}" +
"}";
ObjectMapper objectMapper = new ObjectMapper();
ZZDUserDTO zzdUserDTO;
try {
zzdUserDTO = objectMapper.readValue(json, ZZDUserDTO.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
//暂时先注释,模拟一下就行
// R zzdsso = remoterThirdPartyAuthService.zzdsso(token);
// ZZDUserDTO zzdUserDTO = (ZZDUserDTO) zzdsso.getData();
ZZDUserDTO.Ext ext = zzdUserDTO.getExt();
String employeeCode = ext.getEmployeeCode();
String mobile = zzdUserDTO.getMobile();
//两种方式,手机号+员工钉钉号,查到相同就视为同一个人,然后登录
//先系统表里面查看是否有,在第三方数据库表里面看下是否有
//1.根据手机号或者钉钉号先查询是否有这个人
R<List<SysUser>> userByMobileOrEmployeeCode = remoteUserService.getUserByMobileOrEmployeeCode(employeeCode, mobile);
List<SysUser> sysUserList = userByMobileOrEmployeeCode.getData();
if(!CollectionUtils.isEmpty(sysUserList)){
//登录成功生成token
UserTokenDTO userTokenDTO = new UserTokenDTO();
userTokenDTO.setSysUser(sysUserList.get(0));
remoteTokenService.generateToken(userTokenDTO);
}
//2.根据浙政钉的员工钉钉号查询是否有这个人
R<List<ThirdPartyUserEntity>> thirdPartyUserEntityR=remoterThirdPartyAuthService.getUserByEmployeeCode(employeeCode);
List<ThirdPartyUserEntity> thirdPartyUserEntityList = thirdPartyUserEntityR.getData();
UserDTO userDTO = new UserDTO();
//写死,新加入到部门表的全部归类到其他部门里面,不按照浙政钉的部门
userDTO.setDeptId(1865990541445263361L);
userDTO.setUsername(zzdUserDTO.getUsername());
userDTO.setName(zzdUserDTO.getUsername());
userDTO.setNickname(zzdUserDTO.getUsername());
//浙里办用户
userDTO.setSystemType("1");
//取上面的手机号
userDTO.setPhone(mobile);
userDTO.setDingtalkEmployeeCode(employeeCode);
//随便写死给个密码
userDTO.setPassword("pj"+mobile);
/* if(!CollectionUtils.isEmpty(thirdPartyUserEntityList)){
//todo 这一步现在是没用的,这个最终要考虑是否通过浙政钉里面的人员来限制移动端的使用用户来决定
//第三方数据库里面有
//这里只在第三方库里查到,所以要对系统用户表进行新增当前用户,插入一条信息进去
R<Boolean> booleanR = remoteUserService.registerUser(userDTO);
//通过唯一手机号查询到刚注册的用户信息,生成token
R<List<SysUser>> userByMobile=remoteUserService.getUserByMobile(mobile);
SysUser sysUser = userByMobile.getData().get(0);
UserTokenDTO userTokenDTO = new UserTokenDTO();
userTokenDTO.setSysUser(sysUser);
//登录成功,根据新注册的用户信息返回token
remoteTokenService.generateToken(userTokenDTO);
}
else {
//两个表都没查到人员不能够使用移动端的系统
}*/
// log.info("用户不存在,尝试创建用户");
R<Boolean> booleanR = remoteUserService.registerUser(userDTO);
//通过唯一手机号查询到刚注册的用户信息,生成token
R<List<SysUser>> userByMobile=remoteUserService.getUserByMobile(mobile);
SysUser sysUser = userByMobile.getData().get(0);
int i=8/0;
UserTokenDTO userTokenDTO = new UserTokenDTO();
userTokenDTO.setSysUser(sysUser);
//登录成功,根据新注册的用户信息返回token
return remoteTokenService.generateToken(userTokenDTO);
}
/**
* 诗画sdk获取参数
*
* @param
* @return
*/
@PostMapping("/shjnsso")
@Inner(value = false)
@Operation(summary = "诗画江南单点登录")
public R shjnsso(@RequestBody @Valid SHJNSSODTO shjnsso) {
R rdata = remoterThirdPartyAuthService.shjnsso(shjnsso.getAccountId(), shjnsso.getSessionId());
ZZDUserDTO zzdUserDTO = (ZZDUserDTO) rdata.getData();
ZZDUserDTO.Ext ext = zzdUserDTO.getExt();
String mobile = zzdUserDTO.getMobile();
R<List<SysUser>> rListSysUser = remoteUserService.getUserByMobileOrEmployeeCode(null, mobile);
List<SysUser> sysUsers = rListSysUser.getData();
if(!CollectionUtils.isEmpty(sysUsers)){
//登录成功生成token
UserTokenDTO userTokenDTO = new UserTokenDTO();
userTokenDTO.setSysUser(sysUsers.get(0));
remoteTokenService.generateToken(userTokenDTO);
}
// log.info("用户不存在,尝试创建用户");
// CreateUserDto createUserDto = new CreateUserDto();
// createUserDto.setUsername(zzdUserDTO.getUsername());
// createUserDto.setMobile(mobile);
// createUserDto.setNickname(zzdUserDTO.getNickName());
// createUserDto.setAvatar(ext.getHeadImgUrl());
// //todo 换成枚举
// //诗画用户
// createUserDto.setUserType("2");
// AdminUserRespDTO adminUserRespDTONew=adminUserApi.createSysUser(createUserDto);
// log.info("创建用户成功,用户id为:{}",adminUserRespDTONew.getId());
// return createTokenAfterLoginSuccess(adminUserRespDTONew.getId(), mobile, LoginLogTypeEnum.LOGIN_SSO);
return R.failed("不存在用户");
}
@Inner(value = false)
@GetMapping("/getAuthToken/{token}")
public R info(@PathVariable(name = "token") String token) {
//调用第三方获取基础信息与数据库进行匹配
// 拼接请求参数
UserTokenDTO userTokenDTO = new UserTokenDTO();
userTokenDTO.setUsername("admin"); // 只需要用户名,就可以生成 token
userTokenDTO.setAuthorities(List.of("sys_user_add")); // 权限列表的字符串,能调用的接口范围
// feign 调用 auth 模块生成 token
userTokenDTO.setUsername("admin");
userTokenDTO.setAuthorities(List.of("sys_user_add"));
R<Map> authenticationTokenR = remoteTokenService.generateToken(userTokenDTO);
// // 客户端信息
// RegisteredClient registeredClient = data.getRegisteredClient();
Object accessToken = authenticationTokenR.getData().get("accessToken");
Object refreshToken = authenticationTokenR.getData().get("refreshToken");
Map<String, Object> stringObjectHashMap = new HashMap<>();
@@ -257,142 +50,17 @@ public class AppUserAutoLoginController {
return R.ok(stringObjectHashMap);
}
@GetMapping(value = { "/test" })
@Inner(value = false)
public R info() {
// R<List<CanteenOnlineSpuAppVO>> spuListByIds = remoteOnlineCanteenSpuService.getSpuListByIds(Arrays.asList(1876870696924069890L));
// R<List<CanteenSpuEntity>> spuListByIds = remoteCanteenSpuService.getSpuListByIds(Arrays.asList(1876517188510273537L, 1866411313032441857L));
// List<CanteenOnlineSpuAppVO> data = spuListByIds.getData();
Page<Object> objectPage = new Page<>();
objectPage.setCurrent(1);
objectPage.setSize(10);
DailyMenuDTO dailyMenuDTO = new DailyMenuDTO();
dailyMenuDTO.setMerchantId(16L);
dailyMenuDTO.setStartTime(LocalDate.of(2024,12,16));
dailyMenuDTO.setStartTime(LocalDate.of(2024, 12, 16));
R<Page> pageR = remoteDailyMenuService.selectTimeFoodPage(objectPage, dailyMenuDTO);
return R.ok(pageR);
}
//
// /**
// * 分页查询
// * @param page 分页对象
// * @param appUserDTO app用户表
// * @return
// */
// @Operation(summary = "分页查询", description = "分页查询")
// @GetMapping("/page")
// public R getAppUserPage(Page page, AppUserDTO appUserDTO) {
// return R.ok(appUserService.getUsersWithRolePage(page, appUserDTO));
// }
//
// /**
// * 通过id查询app用户表
// * @param userId id
// * @return R
// */
// @Operation(summary = "通过id查询", description = "通过id查询")
// @GetMapping("/details/{userId}")
// public R getById(@PathVariable("userId") Long userId) {
// return R.ok(appUserService.selectUserVoById(userId));
// }
//
// /**
// * @return R
// */
// @Operation(summary = "通过userName查询", description = "通过userName查询")
// @GetMapping("/details")
// public R getByUserName(AppUser user) {
// AppUser one = appUserService.getOne(Wrappers.query(user), false);
// return R.ok(one == null ? null : CommonConstants.SUCCESS);
// }
//
// /**
// * 新增app用户表
// * @param appUser app用户表
// * @return R
// */
// @Operation(summary = "新增app用户表", description = "新增app用户表")
// @SysLog("新增app用户表")
// @PostMapping
// @HasPermission("app_appuser_add")
// public R save(@RequestBody AppUserDTO appUser) {
// appUserService.saveUser(appUser);
// return R.ok();
// }
//
// /**
// * 修改app用户表
// * @param appUser app用户表
// * @return R
// */
// @Operation(summary = "修改app用户表", description = "修改app用户表")
// @SysLog("修改app用户表")
// @PutMapping
// @HasPermission("app_appuser_edit")
// public R updateById(@RequestBody AppUserDTO appUser) {
// return R.ok(appUserService.updateUser(appUser));
// }
//
// /**
// * 通过id删除app用户表
// * @param ids userIds
// * @return R
// */
// @Operation(summary = "通过ids删除app用户表", description = "通过ids删除app用户表")
// @SysLog("通过id删除app用户表")
// @DeleteMapping
// @HasPermission("app_appuser_del")
// public R removeById(@RequestBody Long[] ids) {
// return R.ok(appUserService.deleteAppUserByIds(ids));
// }
//
// /**
// * 导出excel 表格
// * @param appUser 查询条件
// * @return excel 文件流
// */
// @ResponseExcel
// @GetMapping("/export")
// @HasPermission("app_appuser_export")
// public List<AppUserExcelVO> export(AppUserDTO appUser) {
// return appUserService.listUser(appUser);
// }
//
// @SysLog("修改个人信息")
// @PutMapping("/edit")
// public R updateUserInfo(@Valid @RequestBody AppUserDTO userDto) {
// if (UserTypeEnum.TOC.getStatus().equals(SecurityUtils.getUser().getUserType())) {
// userDto.setUserId(SecurityUtils.getUser().getId());
// // TOC 以手机号为key
// userDto.setUsername(SecurityUtils.getUser().getUsername());
// }
// return appUserService.updateUserInfo(userDto);
// }
//
// /**
// * 导入用户
// * @param excelVOList 用户列表
// * @param bindingResult 错误信息列表
// * @return R
// */
// @PostMapping("/import")
// @HasPermission("app_appuser_export")
// public R importUser(@RequestExcel List<AppUserExcelVO> excelVOList, BindingResult bindingResult) {
// return appUserService.importUser(excelVOList, bindingResult);
// }
//
// @Operation(summary = "注册app用户表", description = "注册app用户表")
// @SysLog("注册app用户表")
// @Inner(value = false)
// @PostMapping("/register")
// public R register(@RequestBody AppUserDTO appUser) {
// return appUserService.registerAppUser(appUser);
// }
}
@@ -16,6 +16,9 @@ public class CanteenSpuExportQuery {
private String merchantId;
/** 上架状态:0上架 1下架 -1回收 */
private Integer status;
/** 勾选导出时的 SPU id */
private List<String> ids;
}
@@ -318,7 +318,7 @@ public class CanteenSpuController {
ids = query.getIds().stream().map(this::parseLongId).filter(Objects::nonNull).toArray(Long[]::new);
}
return canteenSpuImportHelper.toExportRows(
canteenSpuService.listForExport(query.getName(), categoryId, merchantId, ids));
canteenSpuService.listForExport(query.getName(), categoryId, merchantId, query.getStatus(), ids));
}
private Long parseLongId(String raw) {
@@ -74,7 +74,7 @@ public interface CanteenSpuService extends IService<CanteenSpuEntity> {
/**
* 导出列表查询(与列表页 selectPage 同筛选:商户/名称/分类,并排除已关联线上菜品)
*/
List<CanteenSpuEntity> listForExport(String name, Long categoryId, Long merchantId, Long[] ids);
List<CanteenSpuEntity> listForExport(String name, Long categoryId, Long merchantId, Integer status, Long[] ids);
/**
* 分页查询,并且过滤已选择的菜品
@@ -643,8 +643,9 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
.eq(Objects.nonNull(canteenOnlineSpu.getCategoryId()), CanteenOnlineSpuEntity::getCategoryId, canteenOnlineSpu.getCategoryId());
merchantScopeHelper.applyMerchantIdScope(wrapper, canteenOnlineSpu.getMerchantId(), CanteenOnlineSpuEntity::getMerchantId);
wrapper
// 套餐组成只可选单品(spu_type 为空或 1)
// 套餐组成只可选单品(spu_type 为空或 1);排除逻辑删除与回收,仅上架
.and(w -> w.isNull(CanteenOnlineSpuEntity::getSpuType).or().eq(CanteenOnlineSpuEntity::getSpuType, 1))
.and(w -> w.eq(CanteenOnlineSpuEntity::getDelFlag, "0").or().isNull(CanteenOnlineSpuEntity::getDelFlag))
.eq(CanteenOnlineSpuEntity::getStatus, 0)
.orderByAsc(CanteenOnlineSpuEntity::getSort).orderByDesc(CanteenOnlineSpuEntity::getId);
Page<CanteenOnlineSpuEntity> pageData = baseMapper.selectPageByScope(page, wrapper, DataScope.of());
@@ -503,6 +503,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
LambdaQueryWrapper<CanteenSpuEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StrUtil.isNotBlank(canteenSpu.getName()), CanteenSpuEntity::getName, canteenSpu.getName());
wrapper.eq(Objects.nonNull(canteenSpu.getCategoryId()), CanteenSpuEntity::getCategoryId, canteenSpu.getCategoryId());
wrapper.eq(Objects.nonNull(canteenSpu.getStatus()), CanteenSpuEntity::getStatus, canteenSpu.getStatus());
merchantScopeHelper.applyMerchantIdScope(wrapper, canteenSpu.getMerchantId(), CanteenSpuEntity::getMerchantId);
wrapper.orderByAsc(CanteenSpuEntity::getSort).orderByDesc(CanteenSpuEntity::getId);
@@ -511,11 +512,12 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
}
@Override
public List<CanteenSpuEntity> listForExport(String name, Long categoryId, Long merchantId, Long[] ids) {
public List<CanteenSpuEntity> listForExport(String name, Long categoryId, Long merchantId, Integer status, Long[] ids) {
// 与 selectPage(菜品列表页)保持同一套筛选,避免导出比列表多/变成“全部”
LambdaQueryWrapper<CanteenSpuEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StrUtil.isNotBlank(name), CanteenSpuEntity::getName, name);
wrapper.eq(Objects.nonNull(categoryId), CanteenSpuEntity::getCategoryId, categoryId);
wrapper.eq(Objects.nonNull(status), CanteenSpuEntity::getStatus, status);
merchantScopeHelper.applyMerchantIdScope(wrapper, merchantId, CanteenSpuEntity::getMerchantId);
LambdaQueryWrapper<CanteenOnlineSpuEntity> onlineQw = new LambdaQueryWrapper<>();
@@ -569,8 +571,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
LambdaQueryWrapper<CanteenSpuEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StrUtil.isNotBlank(canteenSpu.getName()), CanteenSpuEntity::getName, canteenSpu.getName());
wrapper.eq(Objects.nonNull(canteenSpu.getCategoryId()), CanteenSpuEntity::getCategoryId, canteenSpu.getCategoryId());
// 套餐组成只可选单品(spu_type 为空或 1)
// 套餐组成只可选本商户上架单品(status:0上架 1下架)
wrapper.and(w -> w.isNull(CanteenSpuEntity::getSpuType).or().eq(CanteenSpuEntity::getSpuType, 1));
wrapper.eq(CanteenSpuEntity::getStatus, 0);
merchantScopeHelper.applyMerchantIdScope(wrapper, canteenSpu.getMerchantId(), CanteenSpuEntity::getMerchantId);
wrapper.orderByAsc(CanteenSpuEntity::getSort).orderByDesc(CanteenSpuEntity::getId);
@@ -659,6 +662,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
LambdaQueryWrapper<CanteenSpuEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StrUtil.isNotBlank(canteenSpu.getName()), CanteenSpuEntity::getName, canteenSpu.getName());
wrapper.eq(Objects.nonNull(canteenSpu.getCategoryId()), CanteenSpuEntity::getCategoryId, canteenSpu.getCategoryId());
wrapper.eq(Objects.nonNull(canteenSpu.getStatus()), CanteenSpuEntity::getStatus, canteenSpu.getStatus());
applyHourTypeCategoryFilter(wrapper, canteenSpu.getHourType());
merchantScopeHelper.applyMerchantIdScope(wrapper, canteenSpu.getMerchantId(), CanteenSpuEntity::getMerchantId);
wrapper.orderByAsc(CanteenSpuEntity::getSort).orderByDesc(CanteenSpuEntity::getId);
@@ -28,4 +28,4 @@ security:
encrypt:
enable: true # 开启请求加解密
default-encrypt-type: AES # 可选:AES、SM4,默认 AES
aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位
aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位
@@ -0,0 +1,79 @@
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_callback_task")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "上游酒店回调异步任务")
public class SmHotelCallbackTaskEntity extends Model<SmHotelCallbackTaskEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "PRICE / INVENTORY / ORDER_STATUS(ORDER_STATUS 高优先级)")
private String notifyType;
@Schema(description = "上游 hotelId")
private String hotelCode;
@Schema(description = "本地酒店ID")
private Long hotelId;
private String hotelName;
private String roomTypeId;
private String startDate;
private String endDate;
private String modifyTime;
private String flagInfo;
@Schema(description = "Header time")
private String reqTime;
@Schema(description = "完整回调报文")
private String reqJson;
@Schema(description = "PENDING/RUNNING/SUCCESS/FAILED/SKIPPED")
private String status;
private Integer attemptCount;
private String resultMsg;
private Long costMs;
private LocalDateTime startProcessTime;
private LocalDateTime finishTime;
@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;
}
@@ -0,0 +1,59 @@
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.LocalDate;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_daily")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店日历房")
public class SmHotelDailyEntity extends Model<SmHotelDailyEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long hotelId;
private Long productId;
private String roomTypeCode;
private String productCode;
private LocalDate stayDate;
private BigDecimal costPrice;
private BigDecimal sellPrice;
private Integer stock;
private Integer dailyStatus;
@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;
}
@@ -0,0 +1,50 @@
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_downstream_app")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "下游销售应用")
public class SmHotelDownstreamAppEntity extends Model<SmHotelDownstreamAppEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String appName;
private String appKey;
private String appSecret;
@Schema(description = "1启用 0停用")
private String status;
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;
}
@@ -0,0 +1,84 @@
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")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店管理")
public class SmHotelEntity extends Model<SmHotelEntity> {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键(系统生成)")
private Long id;
private Long tenantId;
private Long deptId;
@Schema(description = "外部Excel中的id")
private String sourceId;
@Schema(description = "集团编码")
private String groupCode;
@Schema(description = "酒店编码")
private String hotelCode;
@Schema(description = "外部集团ID")
private String sGroupId;
@Schema(description = "外部酒店ID")
private String sHotelId;
@Schema(description = "酒店名称")
private String hotelName;
@Schema(description = "导入来源Sheet名")
private String sheetName;
@Schema(description = "ACTIVE启用 / OFFLINE上游下线停用")
private String upstreamStatus;
@Schema(description = "备注")
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;
/** 列表展示:房型数量(非表字段) */
@TableField(exist = false)
@Schema(description = "房型数量")
private Integer roomCount;
/** 列表展示:产品数量(非表字段) */
@TableField(exist = false)
@Schema(description = "产品数量")
private Integer productCount;
/** 列表展示:日历条数(非表字段) */
@TableField(exist = false)
@Schema(description = "日历数量")
private Integer dailyCount;
}
@@ -0,0 +1,108 @@
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.LocalDate;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_order")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店中转订单")
public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long downstreamAppId;
private Long hotelId;
@Schema(description = "下游渠道订单号")
private String channelNo;
@Schema(description = "上游平台订单号")
private String platformNo;
private String groupCode;
private String hotelCode;
private String roomTypeCode;
private String productCode;
private String priceCode;
private LocalDate checkInDate;
private LocalDate checkOutDate;
private Integer count;
private BigDecimal costAmount;
private BigDecimal sellAmount;
private BigDecimal markupPercent;
private String contactName;
private String contactPhone;
private String guestJson;
@Schema(description = "CREATED/PENDING/UPSTREAM_OK/CHECKED_IN/CANCELLED/UPSTREAM_FAIL/PENDING_CONFIRM/NEED_MANUAL")
private String orderStatus;
@Schema(description = "上游 bookStatus:P/R/E/X")
private String bookStatus;
@Schema(description = "上游 payStatus")
private String payStatus;
@Schema(description = "NONE/WAITING/DONE/NEED_MANUAL")
private String compensateStatus;
@Schema(description = "已补偿查询轮次")
private Integer compensateRound;
@Schema(description = "下次补偿时间")
private LocalDateTime nextCompensateTime;
@Schema(description = "最近补偿说明")
private String lastCompensateMsg;
private Integer upstreamState;
private String failMsg;
private String reqJson;
private String upstreamRespJson;
@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;
}
@@ -0,0 +1,49 @@
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_config")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店全局售价调整配置(加价/折扣)")
public class SmHotelPriceConfigEntity extends Model<SmHotelPriceConfigEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
/**
* 售价调整百分比:正数=加价,负数=折扣。
* 例 15 → 卖价=底价×1.15;-10 → 卖价=底价×0.90。须 &gt; -100。
*/
@Schema(description = "售价调整百分比:正数加价、负数折扣,例15=加价15%,-10=打九折")
private BigDecimal markupPercent;
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;
}
@@ -0,0 +1,80 @@
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_product")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店产品")
public class SmHotelProductEntity extends Model<SmHotelProductEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long hotelId;
private Long roomId;
private String groupCode;
private String hotelCode;
private String roomTypeCode;
private String productCode;
private String productName;
private String priceCode;
private String cancelCode;
private Integer mealType;
private Integer mealCount;
@Schema(description = "上游底价")
private BigDecimal costPrice;
@Schema(description = "加价后卖价")
private BigDecimal sellPrice;
private Integer stock;
private Integer apartmentType;
private Integer productType;
private Integer canCancel;
private String cancelRule;
private String rawJson;
@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;
}
@@ -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.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_room")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店房型")
public class SmHotelRoomEntity extends Model<SmHotelRoomEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long hotelId;
private String groupCode;
private String hotelCode;
private String roomTypeCode;
private String roomTypeName;
private String bedType;
private Integer capacity;
private Integer roomNumber;
private String rawJson;
@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;
}
@@ -0,0 +1,85 @@
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_sync_log")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店上游同步日志")
public class SmHotelSyncLogEntity extends Model<SmHotelSyncLogEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "LIST_DIFF / PRICE_ROLL")
private String syncType;
@Schema(description = "MANUAL / XXL / CRON")
private String triggerType;
@Schema(description = "RUNNING / SUCCESS / FAILED")
private String status;
private String stage;
private String stageMsg;
private Integer progressPct;
private Long upstreamCount;
private Long localCount;
private Long newCount;
private Long offlineCount;
private Integer imported;
private Integer offlineMarked;
private Integer reactivated;
private Integer successCount;
private Integer failCount;
private Integer skipCount;
private String errorMsg;
@Schema(description = "失败明细,每行 hotelCode|hotelName|原因")
private String failDetail;
private Long costMs;
private String operator;
@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;
private LocalDateTime finishTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -0,0 +1,28 @@
package com.bsd.pjcloud.supermarket.vo;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "酒店详情(含上游拉取的房型/产品摘要)")
public class SmHotelDetailVO {
private SmHotelEntity hotel;
private List<SmHotelRoomEntity> rooms = new ArrayList<>();
private List<SmHotelProductEntity> products = new ArrayList<>();
@Schema(description = "日历条数(未来含今天)")
private Long dailyCount;
@Schema(description = "日历预览(默认最近一段,完整日历走分页接口)")
private List<SmHotelDailyEntity> dailyPreview = new ArrayList<>();
}
@@ -0,0 +1,45 @@
package com.bsd.pjcloud.supermarket.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "酒店Excel导入任务/结果")
public class SmHotelImportResultVO {
@Schema(description = "任务ID(异步导入)")
private String taskId;
@Schema(description = "RUNNING / SUCCESS / FAILED")
private String status;
@Schema(description = "进度或失败说明")
private String message;
@Schema(description = "处理Sheet数")
private int sheetCount;
@Schema(description = "当前已处理Sheet序号(从1开始)")
private int sheetIndex;
@Schema(description = "读取行数(含跳过)")
private int readCount;
@Schema(description = "新增条数")
private int insertCount;
@Schema(description = "更新条数")
private int updateCount;
@Schema(description = "跳过条数")
private int skipCount;
@Schema(description = "Sheet名称列表")
private List<String> sheetNames = new ArrayList<>();
@Schema(description = "跳过样例(最多50条)")
private List<String> skipSamples = new ArrayList<>();
}
@@ -127,6 +127,11 @@
<artifactId>pjcloud-common-sequence</artifactId>
<version>5.7.0</version>
</dependency>
<!-- 酒店日更 XXL-JOB -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-job</artifactId>
</dependency>
</dependencies>
<profiles>
@@ -0,0 +1,31 @@
package com.bsd.pjcloud.supermarket.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 下游销售方推送配置(本系统作为 A,主动推送到 B 的 /third/party/api/*)。
*/
@Data
@Component
@ConfigurationProperties(prefix = "hotel-downstream")
public class HotelDownstreamProperties {
/** 是否启用向下游推送 */
private boolean enabled = false;
/** 下游 B 系统根地址,例 https://b.example.com ,实际请求 = baseUrl + /third/party/api/hotel/sync */
private String baseUrl = "";
/** B 系统分配给我们的 appKey */
private String appKey = "";
/** B 系统分配给我们的 appSecret(仅本地签名,不落报文) */
private String appSecret = "";
private int timeoutMs = 30000;
/** 拉取上游后自动生成未来日历天数(默认 90≈3个月) */
private int dailyDays = 90;
}
@@ -0,0 +1,207 @@
package com.bsd.pjcloud.supermarket.config;
import cn.hutool.core.util.StrUtil;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 上游玉编电云 / TMC CRS 对接配置(docs/shangyou)。
* <p>
* 测试环境文档是两个地址(鉴权 /link、业务 /link/crs)。
* 生产常只给业务地址,如 https://onlink.yubdianyun.cn/link/provider,
* 此时可只配 {@link #baseUrl},鉴权会自动推导为同域 /link。
*/
@Data
@Component
@ConfigurationProperties(prefix = "shangyou-hotel")
public class ShangyouHotelProperties {
/** 是否启用上游调用 */
private boolean enabled = false;
/**
* 对方给的根地址(生产常见只给这一个)。
* 例:https://onlink.yubdianyun.cn/link/provider
* 未单独配 auth/biz 时:biz=本值;auth=去掉末尾 /provider 或 /crs 后的 /link
*/
private String baseUrl = "";
/** 鉴权根地址;空则从 base-url 推导。例 https://onlink.yubdianyun.cn/link */
private String authBaseUrl = "";
/** 业务根地址;空则用 base-url。例 https://onlink.yubdianyun.cn/link/provider */
private String bizBaseUrl = "";
/** OAuth clientId */
private String clientId = "";
/** OAuth secret */
private String clientSecret = "";
/** 业务卡号 cardId(账号) */
private String cardId = "";
/** 业务秘钥 cardPassWord */
private String cardPassword = "";
/** 回调验签 Secret(默认可用 cardPassword) */
private String callbackSecret = "";
/** 下单预订人 bookerId,可与 cardId 相同,以对方开通为准 */
private String bookerId = "";
/**
* 是否总对总下单(isGroup)。企业中转一般 true;现付调试可改 false。
* true 时会带 bookerPriceType=COMPANY。
*/
private boolean orderIsGroup = true;
/** 担保模式:NONE / FIRST_NIGHT / FULL_AMOUNT,以对方开卡为准 */
private String orderGuaranteeMode = "NONE";
/** 房价接口:生产用 /hotel/getRoomPrice(getTMCRoomPrice 会 404) */
private String roomPricePath = "/hotel/getRoomPrice";
/** 会员等级列表,空则不传 */
private String memberLevels = "";
/** HTTP 超时毫秒 */
private int timeoutMs = 60000;
/** token 提前失效秒数;默认 120 → 缓存约 5 小时 58 分(21600-120),与文档建议一致 */
private int tokenSkewSeconds = 120;
/** 拉取房价天数(建议 1~90,接口单次最多约 31 天会自动切片) */
private int priceDays = 90;
/** 授权酒店列表 Redis 缓存分钟数 */
private int hotelListCacheMinutes = 30;
/** 授权酒店数量告警阈值(仅告警不截断;十几万级会触发) */
private int hotelListWarnSize = 100000;
/**
* 回调无 TENANT-ID 时写入任务/查酒店用的默认租户。
* 酒店中转一般单租户,默认 1。
*/
private Long callbackTenantId = 1L;
/** 回调异步队列:是否启用后台 Worker(Redis BRPOP + 落库记录) */
private boolean callbackQueueEnabled = true;
/** 单条回调任务最大尝试次数(含首次) */
private int callbackMaxAttempts = 3;
/** 回调队列每处理完一家后的间隔毫秒(有 price-api-qps 限流时可略小;默认 50) */
private int callbackTaskIntervalMs = 50;
/** 连续失败达到该次数后,额外冷却毫秒(默认 5 次后冷却 15 秒) */
private int callbackFailPauseAfter = 5;
private int callbackFailPauseMs = 15000;
/**
* 房价接口 getRoomPrice 限流(对方约定约 20 QPS)。
* ≤0 表示不限制;多实例部署时请按实例数均分(如 2 实例各配 10)。
*/
private double priceApiQps = 20;
// ---------- 日更 / 差集(可配置)----------
/** 酒店列表日同步:是否允许定时/XXL 执行(false 则跳过) */
private boolean hotelListDailySyncEnabled = true;
/**
* 是否启用本服务内 Spring Cron(不依赖 XXL)。
* 若已在 XXL 配了同名任务,请保持 false,避免一天跑两次。
*/
private boolean springScheduleEnabled = true;
/**
* 列表日更 Cron(仅 spring-schedule-enabled=true 时生效)。
* 默认每天 02:00:0 0 2 * * ?
*/
private String hotelListDailyCron = "0 0 2 * * ?";
/**
* 滚价日更 Cron(仅 spring-schedule-enabled=true 时生效)。
* 默认每天 03:00:0 0 3 * * ?
*/
private String priceRollDailyCron = "0 0 3 * * ?";
/** 列表同步后是否自动把「上游有本地无」写入本地主数据(会按批循环导完差集) */
private boolean autoImportNewHotels = true;
/**
* 自动导入时每批条数(控内存/事务体积)。同步任务会循环多批直到差集清空,不是「总共只导这么多」。
*/
private int autoImportBatchSize = 2000;
/** 每日滚第 N 天房价(文档首次 90 天后每日落地第 91 天 → 默认 offset=90,即 today+90) */
private boolean priceRollDailyEnabled = true;
/** 滚价日期偏移:today + offset 这一天 */
private int priceRollDayOffset = 90;
/** 滚价每批处理本地酒店数(按 id 游标,不装全表) */
private int priceRollBatchSize = 100;
/** 滚价每家间隔毫秒,减轻上游限流/繁忙(默认 30) */
private int priceRollIntervalMs = 30;
/** 文档:全量房价房量每天最多次数 */
private int fullPriceSyncMaxPerDay = 3;
/** 文档:全量房价两次间隔最少小时数 */
private int fullPriceSyncMinIntervalHours = 6;
/** 全量拉详情:每家酒店间隔毫秒(防打爆上游;落库已批量,默认 50) */
private int detailPullIntervalMs = 50;
/** 全量拉详情单次最多处理家数;0=不限制(慎用,酒店多时耗时长) */
private int detailPullMaxHotels = 0;
/** 差集后是否自动把「本地有上游无」标为 OFFLINE 停用 */
private boolean autoMarkOfflineHotels = true;
/** 是否自动把重新出现在上游的 OFFLINE 酒店恢复为 ACTIVE */
private boolean autoReactivateOnlineHotels = true;
/** 重建本地 code 集合、差集分页时的批大小 */
private int diffScanBatchSize = 500;
/** 实际业务根:显式 biz-base-url > base-url */
public String resolveBizBaseUrl() {
if (StrUtil.isNotBlank(bizBaseUrl)) {
return trimSlash(bizBaseUrl);
}
return trimSlash(baseUrl);
}
/**
* 实际鉴权根:显式 auth-base-url > base-url(与业务同前缀)。
* <p>
* 生产对方只给 {@code .../link/provider} 时,鉴权也走该前缀:
* {@code .../link/provider/oauth/token}。不要再自行拼成 {@code .../link},
* 否则会打到业务网关返回 {@code token为空}。
* 测试若鉴权与业务分离,请显式配置 auth-base-url。
*/
public String resolveAuthBaseUrl() {
if (StrUtil.isNotBlank(authBaseUrl)) {
return trimSlash(authBaseUrl);
}
return trimSlash(baseUrl);
}
private static String trimSlash(String url) {
if (url == null) {
return "";
}
String u = url.trim();
while (u.endsWith("/")) {
u = u.substring(0, u.length() - 1);
}
return u;
}
}
@@ -0,0 +1,12 @@
package com.bsd.pjcloud.supermarket.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 启用 Spring @Scheduled(酒店列表日更 / 滚价可走 YAML Cron,不必依赖 XXL)。
*/
@Configuration
@EnableScheduling
public class SupermarketSchedulingConfig {
}
@@ -0,0 +1,78 @@
package com.bsd.pjcloud.supermarket.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelCallbackTaskEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelCallbackTaskService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
* 上游房价/房量回调任务(后台查询、重试、清理)。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotelCallback")
@Tag(name = "酒店上游回调任务")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelCallbackTaskController {
private final SmHotelCallbackTaskService callbackTaskService;
@Operation(summary = "回调任务分页")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_view')")
public R<Page<SmHotelCallbackTaskEntity>> page(@ParameterObject Page<SmHotelCallbackTaskEntity> page,
@ParameterObject SmHotelCallbackTaskEntity query,
@RequestParam(required = false) String createTimeBegin,
@RequestParam(required = false) String createTimeEnd) {
return R.ok(callbackTaskService.pageTasks(page, query, createTimeBegin, createTimeEnd));
}
@Operation(summary = "回调任务详情")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_view')")
public R<SmHotelCallbackTaskEntity> detail(@PathVariable Long id) {
return R.ok(callbackTaskService.getDetail(id));
}
@SysLog("酒店回调任务重试")
@Operation(summary = "失败/跳过任务重新入队")
@PostMapping("/retry/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_retry')")
public R<Boolean> retry(@PathVariable Long id) {
return callbackTaskService.retry(id);
}
@SysLog("删除酒店回调任务")
@Operation(summary = "删除单条回调任务")
@DeleteMapping("/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_del')")
public R<Boolean> remove(@PathVariable Long id) {
return callbackTaskService.removeTask(id);
}
@SysLog("按时间区间删除酒店回调任务")
@Operation(summary = "按创建时间区间删除回调任务")
@DeleteMapping("/range")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_del')")
public R<Integer> removeRange(@RequestParam(required = false) String createTimeBegin,
@RequestParam(required = false) String createTimeEnd) {
return callbackTaskService.removeByCreateTimeRange(createTimeBegin, createTimeEnd);
}
@SysLog("清空酒店回调任务")
@Operation(summary = "清空全部回调任务并清理 Redis 队列")
@DeleteMapping("/clear")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_del')")
public R<Integer> clearAll() {
return callbackTaskService.clearAllTasks();
}
}
@@ -0,0 +1,100 @@
package com.bsd.pjcloud.supermarket.controller;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelService;
import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotel")
@Tag(name = "酒店管理")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelController {
private final SmHotelService hotelService;
@Operation(summary = "分页查询")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<Page<SmHotelEntity>> page(@ParameterObject Page<SmHotelEntity> page,
@ParameterObject SmHotelEntity query) {
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())
.orderByDesc(SmHotelEntity::getCreateTime)
.orderByDesc(SmHotelEntity::getId);
// 启用:含未写状态的老数据;停用:仅 OFFLINE;空=全部
if ("OFFLINE".equalsIgnoreCase(query.getUpstreamStatus())) {
wrapper.eq(SmHotelEntity::getUpstreamStatus, "OFFLINE");
}
else if ("ACTIVE".equalsIgnoreCase(query.getUpstreamStatus())) {
wrapper.and(w -> w.isNull(SmHotelEntity::getUpstreamStatus)
.or()
.ne(SmHotelEntity::getUpstreamStatus, "OFFLINE"));
}
return R.ok(hotelService.pageScope(page, wrapper));
}
/** 上传文件会立刻 transferTo 临时目录,勿加 @SysLog */
@Operation(summary = "提交Excel异步导入(多Sheet流式)")
@PostMapping("/import")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_import')")
public R<SmHotelImportResultVO> importExcel(@RequestParam("file") MultipartFile file) {
return hotelService.startImport(file);
}
@Operation(summary = "查询酒店导入进度")
@GetMapping("/import/status/{taskId}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_import')")
public R<SmHotelImportResultVO> importStatus(@PathVariable String taskId) {
return hotelService.getImportStatus(taskId);
}
@Operation(summary = "详情")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R getById(@PathVariable Long id) {
return R.ok(hotelService.getById(id));
}
@SysLog("新增酒店")
@Operation(summary = "新增")
@PostMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_add')")
public R save(@RequestBody SmHotelEntity entity) {
return R.ok(hotelService.saveHotel(entity));
}
@SysLog("修改酒店")
@Operation(summary = "修改")
@PutMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R update(@RequestBody SmHotelEntity entity) {
return R.ok(hotelService.updateHotel(entity));
}
@SysLog("删除酒店")
@Operation(summary = "删除")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_del')")
public R remove(@RequestBody Long[] ids) {
return R.ok(hotelService.removeHotels(ids));
}
}
@@ -0,0 +1,68 @@
package com.bsd.pjcloud.supermarket.controller;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAuth;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 下游销售方回传订单接口(免登录,签名鉴权)。
* 路径前缀与草案文档风格对齐:/third/party/hotel/order/*
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/third/party/hotel/order")
@Tag(name = "下游酒店订单回传")
public class SmHotelDownstreamOrderController {
private final DownstreamInboundAuth inboundAuth;
private final SmHotelOrderService orderService;
@Inner(value = false)
@Operation(summary = "下游下单回传(我方再调上游创建)")
@PostMapping("/create")
public R<JSONObject> create(HttpServletRequest request, @RequestBody String rawBody) {
String appKey = header(request, "appKey");
inboundAuth.verify(appKey, header(request, "timestamp"), header(request, "nonce"), header(request, "sign"),
rawBody);
Map<String, Object> body = JSONUtil.parseObj(rawBody);
return orderService.createFromDownstream(appKey, body);
}
@Inner(value = false)
@Operation(summary = "下游取消回传")
@PostMapping("/cancel")
public R<JSONObject> cancel(HttpServletRequest request, @RequestBody String rawBody) {
String appKey = header(request, "appKey");
inboundAuth.verify(appKey, header(request, "timestamp"), header(request, "nonce"), header(request, "sign"),
rawBody);
Map<String, Object> body = JSONUtil.parseObj(rawBody);
return orderService.cancelFromDownstream(appKey, body);
}
@Inner(value = false)
@Operation(summary = "下游查单")
@PostMapping("/query")
public R<JSONObject> query(HttpServletRequest request, @RequestBody String rawBody) {
String appKey = header(request, "appKey");
inboundAuth.verify(appKey, header(request, "timestamp"), header(request, "nonce"), header(request, "sign"),
rawBody);
Map<String, Object> body = JSONUtil.parseObj(rawBody);
return orderService.queryFromDownstream(appKey, body);
}
private static String header(HttpServletRequest request, String name) {
String v = request.getHeader(name);
return v != null ? v : request.getParameter(name);
}
}
@@ -0,0 +1,260 @@
package com.bsd.pjcloud.supermarket.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import com.bsd.pjcloud.supermarket.vo.SmHotelDetailVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotel")
@Tag(name = "酒店中转(拉取/推送/加价)")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelMidController {
private final SmHotelMidService midService;
private final SmHotelPriceConfigService priceConfigService;
private final SmHotelSyncService syncService;
@Operation(summary = "查询全局售价调整配置(加价/折扣)")
@GetMapping("/priceConfig")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')")
public R<SmHotelPriceConfigEntity> getPriceConfig() {
return priceConfigService.getCurrent();
}
@SysLog("保存酒店售价调整配置")
@Operation(summary = "保存全局售价调整配置(正数加价、负数折扣;仅保存,不重算库存卖价)")
@PutMapping("/priceConfig")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')")
public R<Boolean> savePriceConfig(@RequestBody SmHotelPriceConfigEntity entity) {
return priceConfigService.saveOrUpdateConfig(entity);
}
@SysLog("全局同步酒店售价(按配置重算产品/日历,不动订单)")
@Operation(summary = "按当前配置重算全部产品与日历卖价(不动订单)")
@PostMapping("/priceConfig/resyncSell")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')")
public R<Map<String, Object>> resyncSellPrices() {
return priceConfigService.resyncAllSellPrices();
}
@Operation(summary = "酒店详情(含已拉取房型/产品/日历预览)")
@GetMapping("/detail/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<SmHotelDetailVO> detail(@PathVariable Long id) {
return midService.getHotelDetail(id);
}
@Operation(summary = "酒店日历分页")
@GetMapping("/detail/{id}/daily")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<Page<SmHotelDailyEntity>> dailyPage(@PathVariable Long id,
@ParameterObject Page<SmHotelDailyEntity> page,
@RequestParam(required = false) String productCode) {
return midService.pageDaily(id, page, productCode);
}
@Operation(summary = "从上游拉取房型产品并加价落库")
@PostMapping("/mid/pull/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> pull(@PathVariable Long id) {
return midService.pullFromUpstream(id);
}
@Operation(summary = "仅拉取上游房型(getHotelComplexList,不拉房价)")
@PostMapping("/mid/pullRooms/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> pullRooms(@PathVariable Long id) {
return midService.pullRoomsFromUpstream(id);
}
@Operation(summary = "仅拉取上游房价样例(getRoomPrice 今天 1 天)")
@PostMapping("/mid/pullPrice/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> pullPrice(@PathVariable Long id) {
return midService.pullPriceFromUpstream(id);
}
@Operation(summary = "仅拉取上游产品样例(getRoomPrice 今天 1 天落产品)")
@PostMapping("/mid/pullProducts/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> pullProducts(@PathVariable Long id) {
return midService.pullProductsFromUpstream(id);
}
@Operation(summary = "推送到下游销售方")
@PostMapping("/mid/push/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_push')")
public R<Map<String, Object>> push(@PathVariable Long id) {
return midService.pushToDownstream(id);
}
@Operation(summary = "拉取上游并推送下游")
@PostMapping("/mid/pullAndPush/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_push')")
public R<Map<String, Object>> pullAndPush(@PathVariable Long id) {
return midService.pullAndPush(id);
}
@Operation(summary = "批量拉取并推送")
@PostMapping("/mid/pullAndPush/batch")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_push')")
public R<Map<String, Object>> pullAndPushBatch(@RequestBody List<Long> ids) {
return midService.pullAndPushBatch(ids);
}
@Operation(summary = "查询上游账号已授权酒店列表")
@GetMapping("/mid/authorizedHotels")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<Map<String, Object>> authorizedHotels(@RequestParam(required = false) String keyword,
@RequestParam(required = false, defaultValue = "1") long current,
@RequestParam(required = false, defaultValue = "20") long size,
@RequestParam(required = false, defaultValue = "false") boolean refresh) {
return midService.listAuthorizedHotels(keyword, current, size, refresh);
}
@SysLog("导入上游授权酒店到本地")
@Operation(summary = "将上游授权酒店写入本地")
@PostMapping("/mid/authorizedHotels/import")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_add')")
public R<Map<String, Object>> importAuthorizedHotels(@RequestBody List<Map<String, Object>> hotels) {
return midService.importAuthorizedHotels(hotels);
}
@SysLog("同步上游酒店列表并差集")
@Operation(summary = "异步同步 getHotelList + Redis 差集(立刻返回 logId,轮询进度)")
@PostMapping("/mid/syncHotelList")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> syncHotelList(
@RequestParam(required = false, defaultValue = "true") boolean autoImport) {
return syncService.syncHotelListAndDiff(autoImport);
}
@Operation(summary = "同步日志分页")
@GetMapping("/mid/syncLog/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<Page<SmHotelSyncLogEntity>> syncLogPage(@ParameterObject Page<SmHotelSyncLogEntity> page,
@ParameterObject SmHotelSyncLogEntity query) {
return R.ok(syncService.pageSyncLogs(page, query));
}
@Operation(summary = "查询单次同步进度/结果")
@GetMapping("/mid/syncLog/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<SmHotelSyncLogEntity> syncLog(@PathVariable Long id) {
return syncService.getSyncLog(id);
}
@SysLog("终止酒店同步死任务")
@Operation(summary = "终止仍显示进行中的同步日志(死任务)")
@PostMapping("/mid/syncLog/{id}/abort")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Boolean> abortSyncLog(@PathVariable Long id) {
return syncService.abortSyncLog(id);
}
@SysLog("清理酒店同步卡死任务")
@Operation(summary = "清理长时间无进度的 RUNNING 同步日志")
@PostMapping("/mid/syncLog/abortStale")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Integer> abortStaleSyncLogs() {
return R.ok(syncService.abortStaleRunningSyncLogs());
}
@Operation(summary = "日更时间配置(只读展示)")
@GetMapping("/mid/syncSchedule")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<Map<String, Object>> syncSchedule() {
return R.ok(syncService.getSyncSchedule());
}
@Operation(summary = "分页查看酒店差集 new|offline")
@GetMapping("/mid/hotelDiff")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<Map<String, Object>> hotelDiff(@RequestParam(required = false, defaultValue = "new") String type,
@RequestParam(required = false, defaultValue = "1") long current,
@RequestParam(required = false, defaultValue = "20") long size) {
return syncService.pageHotelDiff(type, current, size);
}
@SysLog("导入差集新增酒店")
@Operation(summary = "将差集中的新增酒店导入本地")
@PostMapping("/mid/hotelDiff/importNew")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_add')")
public R<Map<String, Object>> importDiffNew(@RequestParam(required = false) Integer limit) {
return syncService.importDiffNewHotels(limit);
}
@SysLog("手动执行滚一天房价")
@Operation(summary = "异步滚一天房价(立刻返回 logId)")
@PostMapping("/mid/rollDailyPrice")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> rollDailyPrice() {
R<SmHotelSyncLogEntity> started = syncService.startPriceRollAsync("MANUAL");
if (started.getCode() != 0) {
return R.failed(started.getMsg());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
return R.ok(data, started.getMsg());
}
@SysLog("全量拉取启用酒店详情")
@Operation(summary = "异步拉取全部启用酒店详情(房型+产品+未来房价),立刻返回 logId")
@PostMapping("/mid/pullAllActiveDetails")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> pullAllActiveDetails(
@RequestParam(required = false) Integer limit) {
R<SmHotelSyncLogEntity> started = syncService.startDetailPullAsync(limit, "MANUAL");
if (started.getCode() != 0) {
return R.failed(started.getMsg());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
data.put("message", "已提交,请轮询 /mid/syncLog/{id} 或打开同步日志");
return R.ok(data, started.getMsg());
}
@SysLog("重试同步失败酒店")
@Operation(summary = "根据某次同步日志的失败明细,只重试失败酒店(详情/滚价)")
@PostMapping("/mid/retryFailedFromLog")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> retryFailedFromLog(@RequestParam Long logId) {
R<SmHotelSyncLogEntity> started = syncService.startRetryFailedAsync(logId);
if (started.getCode() != 0) {
return R.failed(started.getMsg());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
data.put("sourceLogId", logId);
data.put("message", started.getMsg());
return R.ok(data, started.getMsg());
}
}
@@ -0,0 +1,67 @@
package com.bsd.pjcloud.supermarket.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotelOrder")
@Tag(name = "酒店中转订单")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelOrderController {
private final SmHotelOrderService orderService;
@Operation(summary = "订单分页")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')")
public R<Page<SmHotelOrderEntity>> page(@ParameterObject Page<SmHotelOrderEntity> page,
@ParameterObject SmHotelOrderEntity query) {
return R.ok(orderService.pageOrders(page, query));
}
@Operation(summary = "订单详情")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')")
public R<SmHotelOrderEntity> detail(@PathVariable Long id) {
return R.ok(orderService.getDetail(id));
}
@SysLog("后台取消酒店订单")
@Operation(summary = "后台取消订单")
@PostMapping("/cancel/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_cancel')")
public R<Boolean> cancel(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> body) {
String reason = body == null ? null : String.valueOf(body.getOrDefault("reason", "后台取消"));
return orderService.cancelByAdmin(id, reason);
}
@SysLog("后台试下单(调上游)")
@Operation(summary = "后台试下单:校验可定后调上游 order/add")
@PostMapping("/create")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<cn.hutool.json.JSONObject> createByAdmin(@RequestBody Map<String, Object> body) {
return orderService.createByAdmin(body);
}
@SysLog("酒店订单补偿查询")
@Operation(summary = "立即补偿查询(queryByRequestNo)")
@PostMapping("/compensate/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')")
public R<Boolean> compensate(@PathVariable Long id) {
return orderService.compensateNow(id);
}
}
@@ -0,0 +1,134 @@
package com.bsd.pjcloud.supermarket.controller;
import cn.hutool.json.JSONObject;
import com.bsd.pjcloud.supermarket.service.SmHotelUpstreamService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 玉编电云酒店上游对接(资源查询 / 下单代理 / 变更通知)。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotel/upstream")
@Tag(name = "酒店上游对接")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelUpstreamController {
private final SmHotelUpstreamService upstreamService;
@Operation(summary = "同步上游酒店信息到本地")
@PostMapping("/sync/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<JSONObject> sync(@PathVariable Long id) {
return upstreamService.syncHotelInfo(id);
}
@Operation(summary = "批量同步上游酒店信息")
@PostMapping("/sync/batch")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> syncBatch(@RequestBody List<Long> ids) {
return upstreamService.syncHotelInfoBatch(ids);
}
@Operation(summary = "上游-酒店综合信息")
@PostMapping("/hotelInfo")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<JSONObject> hotelInfo(@RequestBody Map<String, Object> body) {
return upstreamService.hotelInfo(str(body.get("groupCode")), str(body.get("hotelCode")));
}
@Operation(summary = "上游-房型列表")
@PostMapping("/roomTypeList")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<JSONObject> roomTypeList(@RequestBody Map<String, Object> body) {
return upstreamService.roomTypeList(str(body.get("groupCode")), str(body.get("hotelCode")));
}
@Operation(summary = "上游-房价房量样例")
@PostMapping("/productList")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<JSONObject> productList(@RequestBody Map<String, Object> body) {
return upstreamService.productList(str(body.get("groupCode")), str(body.get("hotelCode")),
str(body.get("channelCode")));
}
@Operation(summary = "上游-可定查询")
@PostMapping("/order/check")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<JSONObject> orderCheck(@RequestBody Map<String, Object> body) {
return upstreamService.orderCheck(body);
}
@Operation(summary = "上游-下单")
@PostMapping("/order/create")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<JSONObject> orderCreate(@RequestBody Map<String, Object> body) {
return upstreamService.orderCreate(body);
}
@Operation(summary = "上游-取消订单")
@PostMapping("/order/cancel")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<JSONObject> orderCancel(@RequestBody Map<String, Object> body) {
return upstreamService.orderCancel(body);
}
@Operation(summary = "上游-订单详情")
@PostMapping("/order/getById")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')")
public R<JSONObject> orderGetById(@RequestBody Map<String, Object> body) {
return upstreamService.orderGetById(body);
}
/**
* 房价变更回调(免登录)。Header: time + sign=MD5(Secret+time)
* 响应体按上游文档返回 code/message,不用项目 R 包装。
*/
@Inner(value = false)
@Operation(summary = "上游-房价变更回调")
@PostMapping({ "/msg/notify", "/msg/priceNotify" })
public Map<String, Object> priceNotify(@RequestBody Map<String, Object> body,
@RequestHeader(value = "time", required = false) String time,
@RequestHeader(value = "sign", required = false) String sign) {
return upstreamService.handleShangyouNotify("PRICE", body, time, sign);
}
/**
* 房量预警回调(免登录)。
*/
@Inner(value = false)
@Operation(summary = "上游-房量预警回调")
@PostMapping("/msg/inventoryNotify")
public Map<String, Object> inventoryNotify(@RequestBody Map<String, Object> body,
@RequestHeader(value = "time", required = false) String time,
@RequestHeader(value = "sign", required = false) String sign) {
return upstreamService.handleShangyouNotify("INVENTORY", body, time, sign);
}
/**
* 预订单状态变更回调(免登录)。文档:取消实时推,入住离店日+1 推;非 200 最多重推 10 次。
*/
@Inner(value = false)
@Operation(summary = "上游-预订单状态变更回调")
@PostMapping({ "/msg/orderStatusNotify", "/msg/bookStatusNotify" })
public Map<String, Object> orderStatusNotify(@RequestBody Map<String, Object> body,
@RequestHeader(value = "time", required = false) String time,
@RequestHeader(value = "sign", required = false) String sign) {
return upstreamService.handleShangyouNotify("ORDER_STATUS", body, time, sign);
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}
@@ -0,0 +1,18 @@
package com.bsd.pjcloud.supermarket.integration.downstream;
/**
* 下游销售方(B系统)接收接口路径。
*/
public final class DownstreamHotelApi {
private DownstreamHotelApi() {
}
public static final String HOTEL_SYNC = "/third/party/api/hotel/sync";
public static final String APARTMENT_SYNC = "/third/party/api/apartment/sync";
public static final String PRODUCT_SYNC = "/third/party/api/product/sync";
public static final String DAILY_SYNC = "/third/party/api/daily/sync";
}
@@ -0,0 +1,97 @@
package com.bsd.pjcloud.supermarket.integration.downstream;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 向下游销售方(B系统)推送酒店/房型/产品/日历房。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DownstreamHotelClient {
private static final String TAG = "下游酒店推送";
private final HotelDownstreamProperties properties;
public void assertEnabled() {
if (!properties.isEnabled()) {
throw new CheckedException("下游推送未启用,请配置 hotel-downstream.enabled=true");
}
if (StrUtil.isBlank(properties.getBaseUrl()) || StrUtil.isBlank(properties.getAppKey())
|| StrUtil.isBlank(properties.getAppSecret())) {
throw new CheckedException("下游推送配置不完整:base-url / app-key / app-secret");
}
}
public JSONObject hotelSync(Object body) {
return post(DownstreamHotelApi.HOTEL_SYNC, body);
}
public JSONObject apartmentSync(Object body) {
return post(DownstreamHotelApi.APARTMENT_SYNC, body);
}
public JSONObject productSync(Object body) {
return post(DownstreamHotelApi.PRODUCT_SYNC, body);
}
public JSONObject dailySync(Object body) {
return post(DownstreamHotelApi.DAILY_SYNC, body);
}
private JSONObject post(String path, Object bodyObj) {
assertEnabled();
String bodyJson = bodyObj instanceof String ? (String) bodyObj : JSONUtil.toJsonStr(bodyObj);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = IdUtil.fastSimpleUUID();
String sign = DownstreamSignHelper.sign(properties.getAppKey(), properties.getAppSecret(), timestamp, nonce,
bodyJson);
String url = trimSlash(properties.getBaseUrl()) + path;
log.info("|{}|req|path={}|body={}|", TAG, path, bodyJson);
try (HttpResponse response = HttpRequest.post(url)
.timeout(properties.getTimeoutMs())
.header("Content-Type", "application/json;charset=UTF-8")
.header("appKey", properties.getAppKey())
.header("timestamp", timestamp)
.header("nonce", nonce)
.header("sign", sign)
.body(bodyJson)
.execute()) {
String res = response.body();
log.info("|{}|res|path={}|http={}|body={}|", TAG, path, response.getStatus(), res);
if (StrUtil.isBlank(res)) {
throw new CheckedException("下游响应为空: " + path);
}
JSONObject json = JSONUtil.parseObj(res);
Integer code = json.getInt("code");
if (code == null || code != 200) {
throw new CheckedException(StrUtil.format("下游失败[{}] code={} msg={}", path, code, json.getStr("msg")));
}
return json;
}
catch (CheckedException e) {
throw e;
}
catch (Exception e) {
throw new CheckedException("下游调用失败: " + e.getMessage());
}
}
private static String trimSlash(String base) {
if (base == null) {
return "";
}
return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
}
}
@@ -0,0 +1,60 @@
package com.bsd.pjcloud.supermarket.integration.downstream;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 校验下游回传订单请求签名(与同步文档同一套 MD5 规则)。
*/
@Component
@RequiredArgsConstructor
public class DownstreamInboundAuth {
private static final String NONCE_KEY = "canteen:hotel:downstream:nonce:";
private final SmHotelDownstreamAppMapper appMapper;
private final StringRedisTemplate redisTemplate;
public SmHotelDownstreamAppEntity verify(String appKey, String timestamp, String nonce, String sign,
String bodyJson) {
if (StrUtil.isBlank(appKey) || StrUtil.isBlank(timestamp) || StrUtil.isBlank(nonce) || StrUtil.isBlank(sign)) {
throw new CheckedException("缺少鉴权参数");
}
long ts;
try {
ts = Long.parseLong(timestamp);
}
catch (Exception e) {
throw new CheckedException("timestamp 非法");
}
if (Math.abs(System.currentTimeMillis() - ts) > 5 * 60 * 1000L) {
throw new CheckedException("签名过期");
}
Boolean ok = redisTemplate.opsForValue().setIfAbsent(NONCE_KEY + appKey + ":" + nonce, "1", 5, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(ok)) {
throw new CheckedException("nonce 重复");
}
SmHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getAppKey, appKey)
.eq(SmHotelDownstreamAppEntity::getStatus, "1")
.last("limit 1"));
if (app == null) {
throw new CheckedException("appKey 无效");
}
String expect = DownstreamSignHelper.sign(appKey, app.getAppSecret(), timestamp, nonce, bodyJson);
if (!expect.equalsIgnoreCase(sign)) {
throw new CheckedException("签名错误");
}
return app;
}
}
@@ -0,0 +1,29 @@
package com.bsd.pjcloud.supermarket.integration.downstream;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.core.util.StrUtil;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
* 下游同步鉴权签名(《第三方数据同步接口文档》2.1):
* MD5(appKey=&body=&nonce=&timestamp=&appSecret=).toUpperCase()
*/
public final class DownstreamSignHelper {
private DownstreamSignHelper() {
}
public static String sign(String appKey, String appSecret, String timestamp, String nonce, String bodyJson) {
Map<String, String> params = new TreeMap<>();
params.put("appKey", StrUtil.nullToEmpty(appKey));
params.put("body", StrUtil.nullToEmpty(bodyJson));
params.put("nonce", StrUtil.nullToEmpty(nonce));
params.put("timestamp", StrUtil.nullToEmpty(timestamp));
String stringA = params.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining("&"));
return DigestUtil.md5Hex(stringA + "&appSecret=" + StrUtil.nullToEmpty(appSecret)).toUpperCase();
}
}
@@ -0,0 +1,52 @@
package com.bsd.pjcloud.supermarket.integration.shangyou;
import cn.hutool.core.util.StrUtil;
/**
* 上游 bookStatus / 本地 orderStatus 映射(订单相关接口模式.pdf 枚举)。
* <ul>
* <li>P = 处理中</li>
* <li>R = 预订成功</li>
* <li>E = 入住/离店/NoShow(pmsAdapter I/O/N 映射为 bookStatus E)</li>
* <li>X = 已取消</li>
* </ul>
*/
public final class ShangyouBookStatus {
public static final String P = "P";
public static final String R = "R";
public static final String E = "E";
public static final String X = "X";
private ShangyouBookStatus() {
}
/** 本地订单状态:与 PC 列表兼容并扩展 */
public static String toLocalOrderStatus(String bookStatus) {
if (StrUtil.isBlank(bookStatus)) {
return null;
}
return switch (bookStatus.trim().toUpperCase()) {
case P -> "PENDING";
case R -> "UPSTREAM_OK";
case E -> "CHECKED_IN";
case X -> "CANCELLED";
default -> bookStatus;
};
}
public static boolean isTerminal(String bookStatus) {
if (StrUtil.isBlank(bookStatus)) {
return false;
}
String s = bookStatus.trim().toUpperCase();
return R.equals(s) || E.equals(s) || X.equals(s);
}
public static boolean isProcessing(String bookStatus) {
return P.equalsIgnoreCase(StrUtil.trim(bookStatus));
}
}
@@ -0,0 +1,158 @@
package com.bsd.pjcloud.supermarket.integration.shangyou;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelCallbackTaskService;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 消费 Redis 回调队列:BRPOP 高优队列优先于普通队列。
* <p>
* 另起看门狗线程:即使 Worker 卡在某次上游调用里,也能把超时 RUNNING 重置入队,避免整队假死。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ShangyouCallbackQueueWorker implements ApplicationRunner {
private final ShangyouHotelProperties properties;
private final StringRedisTemplate redisTemplate;
private final SmHotelCallbackTaskService callbackTaskService;
private final AtomicBoolean running = new AtomicBoolean(true);
private Thread worker;
private Thread watchdog;
@Override
public void run(ApplicationArguments args) {
if (!properties.isCallbackQueueEnabled()) {
log.info("|回调队列|Worker 未启用(shangyou-hotel.callback-queue-enabled=false)");
return;
}
try {
callbackTaskService.requeueStalePending();
}
catch (Exception e) {
log.warn("|回调队列|恢复 PENDING 失败(可稍后人工重试)", e);
}
worker = new Thread(this::loop, "shangyou-callback-queue");
worker.setDaemon(true);
worker.start();
watchdog = new Thread(this::watchdogLoop, "shangyou-callback-watchdog");
watchdog.setDaemon(true);
watchdog.start();
log.info("|回调队列|Worker+Watchdog 已启动|high={}|normal={}|",
ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH, ShangyouHotelApi.REDIS_CALLBACK_QUEUE);
}
private void loop() {
int consecutiveFail = 0;
while (running.get()) {
try {
// BRPOP 多 key:先高优(订单状态)再普通;阻塞期间新到的高优也会被优先弹出
List<String> popped = redisTemplate.opsForList().rightPop(
List.of(ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH,
ShangyouHotelApi.REDIS_CALLBACK_QUEUE),
Duration.ofSeconds(5));
if (popped == null || popped.size() < 2 || popped.get(1) == null) {
continue;
}
String taskId = popped.get(1).trim();
if (taskId.isBlank() || "null".equalsIgnoreCase(taskId)) {
continue;
}
Long id;
try {
id = Long.valueOf(taskId.trim());
}
catch (NumberFormatException e) {
log.warn("|回调队列|非法 taskId={}|", taskId);
continue;
}
boolean ok = callbackTaskService.processQueuedTask(id);
int interval = Math.max(0, properties.getCallbackTaskIntervalMs());
if (interval > 0) {
Thread.sleep(interval);
}
if (ok) {
consecutiveFail = 0;
}
else {
consecutiveFail++;
int pauseAfter = Math.max(1, properties.getCallbackFailPauseAfter());
if (consecutiveFail >= pauseAfter) {
int pauseMs = Math.max(0, properties.getCallbackFailPauseMs());
log.warn("|回调队列|连续失败 {} 次,冷却 {}ms 后再继续|", consecutiveFail, pauseMs);
if (pauseMs > 0) {
Thread.sleep(pauseMs);
}
consecutiveFail = 0;
}
}
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
catch (Exception e) {
if (!running.get()) {
break;
}
log.error("|回调队列|消费异常", e);
try {
Thread.sleep(2000L);
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
/** 不依赖 Worker 空闲:定时扫卡死的 RUNNING */
private void watchdogLoop() {
while (running.get()) {
try {
Thread.sleep(30_000L);
if (!running.get()) {
break;
}
callbackTaskService.recoverStaleRunning();
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
catch (Exception e) {
log.warn("|回调队列|Watchdog 恢复失败|{}", e.getMessage());
}
}
}
@PreDestroy
public void shutdown() {
running.set(false);
if (worker != null) {
worker.interrupt();
}
if (watchdog != null) {
watchdog.interrupt();
}
}
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.supermarket.integration.shangyou;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
/**
* 上游变更回调验签:sign = MD5(Secret + time)
*/
public final class ShangyouCallbackSignHelper {
private ShangyouCallbackSignHelper() {
}
public static boolean verify(String secret, String time, String sign) {
if (StrUtil.hasBlank(secret, time, sign)) {
return false;
}
String expect = DigestUtil.md5Hex(secret + time);
return expect.equalsIgnoreCase(sign);
}
}
@@ -0,0 +1,70 @@
package com.bsd.pjcloud.supermarket.integration.shangyou;
/**
* 玉编电云 CRS 上游路径(相对 biz-base-url / auth-base-url)。
*/
public final class ShangyouHotelApi {
private ShangyouHotelApi() {
}
public static final String OAUTH_TOKEN = "/oauth/token?scope=ALL&grant_type=client_credentials";
public static final String HOTEL_LIST = "/hotel/getHotelList";
public static final String HOTEL_COMPLEX = "/hotel/getHotelComplexList";
public static final String CHECK_AVAILABILITY = "/hotel/checkAvailability";
public static final String ORDER_ADD = "/order/add";
public static final String ORDER_CANCEL = "/order/cancel";
public static final String ORDER_QUERY = "/order/query";
public static final String ORDER_QUERY_BY_REQUEST_NO = "/order/queryByRequestNo";
/** 上游 access_token 缓存(全局单票;新票会使旧票失效,必须 compare-and-refresh) */
public static final String REDIS_TOKEN_KEY = "supermarket:shangyou:hotel:token";
public static final String REDIS_TOKEN_LOCK = "supermarket:shangyou:hotel:token:lock";
/** 最近一次成功刷新 Token 的时间戳(毫秒),用于合并并发刷新,避免互踢旧 Token */
public static final String REDIS_TOKEN_REFRESHED_AT = "supermarket:shangyou:hotel:token:refreshedAt";
/** 授权酒店 HASH:field=hotelId value=hotelName(适配十几万量级) */
public static final String REDIS_HOTEL_HASH_KEY = "supermarket:shangyou:hotel:hash:v4";
/** 授权酒店 ZSET:score=序号 member=hotelId,深分页 O(logN) */
public static final String REDIS_HOTEL_ZSET_KEY = "supermarket:shangyou:hotel:zset:v4";
/** 酒店列表同步锁 */
public static final String REDIS_HOTEL_LIST_LOCK = "supermarket:shangyou:hotel:list:lock";
/** 回调异步队列(LIST,元素=taskId):房价/房量等普通优先级 */
public static final String REDIS_CALLBACK_QUEUE = "supermarket:shangyou:hotel:callback:queue";
/** 回调高优先级队列:订单状态等,Worker 始终先消费本队列 */
public static final String REDIS_CALLBACK_QUEUE_HIGH = "supermarket:shangyou:hotel:callback:queue:high";
/** 上游授权 hotelId 集合(与 HASH/ZSET 同步重建,供 SDIFF) */
public static final String REDIS_UPSTREAM_ID_SET = "supermarket:shangyou:hotel:idset:v4";
/** 本地 hotel_code 集合(分批从 DB 重建) */
public static final String REDIS_LOCAL_CODE_SET = "supermarket:shangyou:hotel:local:codes:v1";
/** 差集:上游有本地无(待导入) */
public static final String REDIS_DIFF_NEW_SET = "supermarket:shangyou:hotel:diff:new:v1";
/** 差集:本地有上游无(疑似下线) */
public static final String REDIS_DIFF_OFFLINE_SET = "supermarket:shangyou:hotel:diff:offline:v1";
/** 今日全量房价同步次数 */
public static final String REDIS_FULL_PRICE_SYNC_COUNT = "supermarket:shangyou:hotel:fullprice:count";
/** 上次全量房价同步时间 epochMs */
public static final String REDIS_FULL_PRICE_SYNC_LAST = "supermarket:shangyou:hotel:fullprice:last";
/** 房价接口 QPS 秒窗计数前缀,完整 key = 前缀 + ":" + epochSecond */
public static final String REDIS_PRICE_QPS_PREFIX = "supermarket:shangyou:hotel:price:qps";
}
@@ -0,0 +1,954 @@
package com.bsd.pjcloud.supermarket.integration.shangyou;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.DefaultTypedTuple;
import org.springframework.data.redis.core.ZSetOperations;
/**
* 玉编电云 CRS 客户端:Basic 换 Token,业务请求 Bearer + cardId/cardPassWord。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ShangyouHotelClient {
private final ShangyouHotelProperties properties;
private final StringRedisTemplate redisTemplate;
public boolean isEnabled() {
return properties.isEnabled();
}
public void ensureEnabled() {
if (!properties.isEnabled()) {
throw new CheckedException("上游酒店未启用,请配置 shangyou-hotel.enabled=true");
}
if (StrUtil.hasBlank(properties.resolveAuthBaseUrl(), properties.resolveBizBaseUrl(), properties.getClientId(),
properties.getClientSecret(), properties.getCardId(), properties.getCardPassword())) {
throw new CheckedException(
"上游酒店配置不完整:至少配置 base-url(或 auth-base-url+biz-base-url)以及 clientId/secret、cardId/password");
}
}
public String getAccessToken() {
return getAccessToken(false);
}
/**
* 同 JVM 内互斥,避免多线程同时抢 Redis 锁 + oauth。
* 上游约定:新 Token 会使旧 Token 立即失效,因此「只允许在缓存仍是失效 Token 时 oauth 一次」。
*/
private final Object tokenRefreshMonitor = new Object();
/** 两次 oauth 最小间隔,防止 504 风暴下连环换票互踢 */
private static final long OAUTH_MIN_INTERVAL_MS = 12_000L;
/**
* 取 Token。forceRefresh=true 时按 invalidToken 做 compare-and-refresh:
* 缓存只要已经不是失效票,一律复用,绝不二次 oauth。
*/
public String getAccessToken(boolean forceRefresh) {
return getAccessToken(forceRefresh, null);
}
public String getAccessToken(boolean forceRefresh, String invalidToken) {
ensureEnabled();
String cached = readCachedToken();
if (!forceRefresh) {
if (StrUtil.isNotBlank(cached)) {
return cached;
}
}
else if (StrUtil.isNotBlank(invalidToken) && StrUtil.isNotBlank(cached) && !cached.equals(invalidToken)) {
// 别人(或上一轮)已经换过新票,本请求绝不能再 oauth,否则会踢掉正在用的新票
log.info("shangyou reuse rotated token, skip oauth (invalid!=cache)");
return cached;
}
else if (forceRefresh && StrUtil.isBlank(invalidToken) && StrUtil.isNotBlank(cached)) {
// 未指明失效票时不允许盲目换票
return cached;
}
synchronized (tokenRefreshMonitor) {
return refreshTokenSynchronized(invalidToken);
}
}
/** 已持本地锁:再抢 Redis 锁,双检后才 oauth */
private String refreshTokenSynchronized(String invalidToken) {
// 进锁后再读一次,避免排队期间别人已换好
String reused = tryReuseValidToken(invalidToken);
if (StrUtil.isNotBlank(reused)) {
return reused;
}
long lockTtlSec = Math.max(45L, properties.getTimeoutMs() / 1000L + 15L);
for (int attempt = 0; attempt < 4; attempt++) {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(ShangyouHotelApi.REDIS_TOKEN_LOCK, "1", lockTtlSec, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
return refreshTokenUnderLock(invalidToken);
}
finally {
redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_LOCK);
}
}
String waited = waitForCachedToken(2_000L, invalidToken);
if (StrUtil.isNotBlank(waited)) {
log.info("shangyou wait lock got token from peer, skip oauth");
return waited;
}
}
String last = tryReuseValidToken(invalidToken);
if (StrUtil.isNotBlank(last)) {
return last;
}
throw new CheckedException("获取上游 Token 超时(刷新锁竞争)");
}
/**
* 已持 Redis 锁。规则:
* <ul>
* <li>缓存为空 → oauth</li>
* <li>指定了 invalidToken 且缓存仍等于它 → oauth(必要时等冷却)</li>
* <li>缓存已是别的票 → 直接复用</li>
* <li>盲目刷新但冷却期内 → 复用</li>
* </ul>
*/
private String refreshTokenUnderLock(String invalidToken) {
String cached = readCachedToken();
if (StrUtil.isNotBlank(cached)) {
if (StrUtil.isBlank(invalidToken)) {
// 没有明确的失效票时,绝不主动 oauth(新票会踢旧票)
log.info("shangyou under-lock reuse cache (no invalidToken), skip oauth");
return cached;
}
if (!cached.equals(invalidToken)) {
log.info("shangyou under-lock reuse rotated token, skip oauth");
return cached;
}
if (wasTokenRefreshedRecently(OAUTH_MIN_INTERVAL_MS)) {
// 新票刚换出来又立刻 504:绝不能在请求线程睡满冷却(会堵死单线程回调队列)。
// 直接抛出让上层任务失败入队尾,等冷却过后再由后续任务触发 oauth。
long remain = oauthCooldownRemainMs();
throw new CheckedException("上游 Token 刚刷新仍返回504," + remain
+ "ms 内不重复换票(避免互踢/堵队列),请稍后重试");
}
}
return fetchAndCacheToken();
}
/** 缓存可用且(未指定失效票 / 已不是失效票)则复用 */
private String tryReuseValidToken(String invalidToken) {
String cached = readCachedToken();
if (StrUtil.isBlank(cached)) {
return null;
}
if (StrUtil.isBlank(invalidToken) || !cached.equals(invalidToken)) {
return cached;
}
return null;
}
private String readCachedToken() {
return redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_TOKEN_KEY);
}
private boolean wasTokenRefreshedRecently(long withinMs) {
return oauthCooldownRemainMs(withinMs) > 0L;
}
private long oauthCooldownRemainMs() {
return oauthCooldownRemainMs(OAUTH_MIN_INTERVAL_MS);
}
private long oauthCooldownRemainMs(long withinMs) {
String at = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_TOKEN_REFRESHED_AT);
if (StrUtil.isBlank(at)) {
return 0L;
}
try {
long ts = Long.parseLong(at.trim());
long remain = withinMs - (System.currentTimeMillis() - ts);
return Math.max(0L, remain);
}
catch (Exception e) {
return 0L;
}
}
/**
* 等待缓存出现「可用」Token:非空,且若指定了 invalidToken 则必须已换成另一张。
*/
private String waitForCachedToken(long timeoutMs, String invalidToken) {
long deadline = System.currentTimeMillis() + Math.max(1000L, timeoutMs);
while (System.currentTimeMillis() < deadline) {
String ok = tryReuseValidToken(invalidToken);
if (StrUtil.isNotBlank(ok)) {
return ok;
}
sleepQuiet(150L);
}
return tryReuseValidToken(invalidToken);
}
private String fetchAndCacheToken() {
String url = properties.resolveAuthBaseUrl() + ShangyouHotelApi.OAUTH_TOKEN;
String basic = Base64.getEncoder()
.encodeToString((properties.getClientId() + ":" + properties.getClientSecret())
.getBytes(StandardCharsets.UTF_8));
log.info("shangyou oauth token url={}", url);
try (HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Basic " + basic)
.header("Accept", "*/*")
.header("Accept-Encoding", "gzip, deflate")
.header("Content-Type", "application/json;charset=utf-8")
.header("Connection", "Keep-Alive")
.timeout(properties.getTimeoutMs())
.execute()) {
String body = response.body();
if (!response.isOk()) {
throw new CheckedException("上游鉴权失败 HTTP=" + response.getStatus() + " body=" + body);
}
JSONObject resp = JSON.parseObject(body);
String token = firstNonBlank(resp.getString("access_token"), resp.getString("accessToken"));
if (StrUtil.isBlank(token)) {
throw new CheckedException("上游鉴权未返回 access_token: " + body + "(请核对 auth 地址是否为 "
+ properties.resolveAuthBaseUrl() + "/oauth/token )");
}
long expires = resp.getLongValue("expires_in");
if (expires <= 0) {
expires = 21600L;
}
// 文档建议缓存约 5 小时 58 分:expires_in(21600) - skew(默认120秒)
long ttl = Math.max(60L, expires - properties.getTokenSkewSeconds());
redisTemplate.opsForValue().set(ShangyouHotelApi.REDIS_TOKEN_KEY, token, ttl, TimeUnit.SECONDS);
redisTemplate.opsForValue()
.set(ShangyouHotelApi.REDIS_TOKEN_REFRESHED_AT, String.valueOf(System.currentTimeMillis()), ttl,
TimeUnit.SECONDS);
log.info("shangyou oauth ok, token cached ttlSec={} tokenTail={}", ttl,
token.length() > 8 ? token.substring(token.length() - 8) : token);
return token;
}
}
/**
* 授权酒店列表:不落 JVM 全量对象。
* Redis = HASH(hotelId→name) + ZSET(序号→hotelId),支持十几万级深分页。
*/
public JSONArray getHotelList() {
HotelPage page = pageHotelBriefs(null, 1, 500, false);
JSONArray arr = new JSONArray();
for (HotelBrief b : page.records()) {
JSONObject o = new JSONObject();
o.put("hotelId", b.hotelId());
o.put("hotelName", b.hotelName());
arr.add(o);
}
return arr;
}
/**
* 分页查询授权酒店。
* <ul>
* <li>无关键字:ZRANGE + HMGET,深分页也稳定</li>
* <li>精确 hotelId:HGET O(1)</li>
* <li>模糊关键字:ZRANGE 分批 + HMGET 内存过滤(避开 Redisson 与 Spring Data Redis 3.3 的 HSCAN/Cursor 不兼容)</li>
* </ul>
*/
public HotelPage pageHotelBriefs(String keyword, long current, long size, boolean forceRefresh) {
ensureHotelListCached(forceRefresh);
if (current < 1) {
current = 1;
}
if (size < 1) {
size = 20;
}
if (size > 100) {
size = 100;
}
Long totalLen = redisTemplate.opsForZSet().zCard(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY);
long len = totalLen == null ? 0L : totalLen;
if (len <= 0) {
return new HotelPage(Collections.emptyList(), 0L);
}
String kw = StrUtil.trimToEmpty(keyword);
if (StrUtil.isBlank(kw)) {
long start = (current - 1) * size;
if (start >= len) {
return new HotelPage(Collections.emptyList(), len);
}
long end = Math.min(start + size - 1, len - 1);
Set<String> ids = redisTemplate.opsForZSet()
.range(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY, start, end);
return new HotelPage(loadBriefsByIds(ids), len);
}
// 精确 hotelId
Object exactName = redisTemplate.opsForHash().get(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY, kw);
if (exactName != null) {
if (current > 1) {
return new HotelPage(Collections.emptyList(), 1L);
}
return new HotelPage(Collections.singletonList(new HotelBrief(kw, String.valueOf(exactName))), 1L);
}
String kwLower = kw.toLowerCase();
long skip = (current - 1) * size;
long need = size;
long matched = 0;
List<HotelBrief> page = new ArrayList<>((int) size);
final long batch = 500L;
try {
for (long start = 0; start < len; start += batch) {
long end = Math.min(start + batch - 1, len - 1);
Set<String> ids = redisTemplate.opsForZSet()
.range(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY, start, end);
List<HotelBrief> briefs = loadBriefsByIds(ids);
for (HotelBrief b : briefs) {
String hotelId = b.hotelId() == null ? "" : b.hotelId();
String hotelName = b.hotelName() == null ? "" : b.hotelName();
if (!hotelId.toLowerCase().contains(kwLower) && !hotelName.toLowerCase().contains(kwLower)) {
continue;
}
if (matched >= skip && page.size() < need) {
page.add(b);
}
matched++;
}
}
}
catch (Exception ex) {
log.warn("|上游酒店列表|关键字扫描异常|err={}|", ex.getMessage());
}
return new HotelPage(page, matched);
}
public void clearHotelListCache() {
redisTemplate.delete(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY);
redisTemplate.delete(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY);
redisTemplate.delete(ShangyouHotelApi.REDIS_UPSTREAM_ID_SET);
// 兼容清理旧版 LIST/压缩缓存 key
redisTemplate.delete("supermarket:shangyou:hotel:list:v3");
redisTemplate.delete("supermarket:shangyou:hotel:list:v2");
}
private List<HotelBrief> loadBriefsByIds(Set<String> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
List<Object> idList = new ArrayList<>(ids.size());
idList.addAll(ids);
List<Object> names = redisTemplate.opsForHash().multiGet(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY, idList);
List<HotelBrief> list = new ArrayList<>(idList.size());
for (int i = 0; i < idList.size(); i++) {
String id = String.valueOf(idList.get(i));
Object name = names == null || i >= names.size() ? null : names.get(i);
list.add(new HotelBrief(id, name == null ? "" : String.valueOf(name)));
}
return list;
}
private void ensureHotelListCached(boolean forceRefresh) {
Long size = redisTemplate.opsForZSet().zCard(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY);
if (!forceRefresh && size != null && size > 0) {
log.info("|上游酒店列表|命中Redis ZSET/HASH|count={}|", size);
return;
}
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(ShangyouHotelApi.REDIS_HOTEL_LIST_LOCK, "1", 5, TimeUnit.MINUTES);
try {
size = redisTemplate.opsForZSet().zCard(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY);
if (!forceRefresh && size != null && size > 0) {
return;
}
syncHotelListFromUpstream();
}
finally {
if (Boolean.TRUE.equals(locked)) {
redisTemplate.delete(ShangyouHotelApi.REDIS_HOTEL_LIST_LOCK);
}
}
}
private void syncHotelListFromUpstream() {
long t0 = System.currentTimeMillis();
JSONObject body = baseCardBody();
JSONObject resp = postBiz(ShangyouHotelApi.HOTEL_LIST, body);
JSONArray arr = asArray(resp.get("content"));
resp.remove("content");
int count = arr.size();
if (count >= properties.getHotelListWarnSize()) {
log.warn("|上游酒店列表|数量较大|count={}|warnSize={}|同步期会有短时内存峰值,建议低峰强制刷新|", count,
properties.getHotelListWarnSize());
}
String tmpHash = ShangyouHotelApi.REDIS_HOTEL_HASH_KEY + ":tmp";
String tmpZset = ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY + ":tmp";
String tmpSet = ShangyouHotelApi.REDIS_UPSTREAM_ID_SET + ":tmp";
redisTemplate.delete(tmpHash);
redisTemplate.delete(tmpZset);
redisTemplate.delete(tmpSet);
try {
Map<String, String> hashBatch = new HashMap<>(512);
Set<ZSetOperations.TypedTuple<String>> zsetBatch = new HashSet<>(512);
Set<String> idBatch = new HashSet<>(512);
for (int i = 0; i < count; i++) {
JSONObject item = arr.getJSONObject(i);
String hotelId = item.getString("hotelId");
if (StrUtil.isBlank(hotelId)) {
continue;
}
String hotelName = StrUtil.nullToEmpty(item.getString("hotelName"));
hashBatch.put(hotelId, hotelName);
zsetBatch.add(new DefaultTypedTuple<>(hotelId, (double) i));
idBatch.add(hotelId);
if (hashBatch.size() >= 500) {
redisTemplate.opsForHash().putAll(tmpHash, hashBatch);
redisTemplate.opsForZSet().add(tmpZset, zsetBatch);
redisTemplate.opsForSet().add(tmpSet, idBatch.toArray(new String[0]));
hashBatch.clear();
zsetBatch.clear();
idBatch.clear();
}
}
if (!hashBatch.isEmpty()) {
redisTemplate.opsForHash().putAll(tmpHash, hashBatch);
redisTemplate.opsForZSet().add(tmpZset, zsetBatch);
redisTemplate.opsForSet().add(tmpSet, idBatch.toArray(new String[0]));
}
// 切换正式 key
redisTemplate.delete(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY);
redisTemplate.delete(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY);
redisTemplate.delete(ShangyouHotelApi.REDIS_UPSTREAM_ID_SET);
if (count > 0) {
redisTemplate.rename(tmpHash, ShangyouHotelApi.REDIS_HOTEL_HASH_KEY);
redisTemplate.rename(tmpZset, ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY);
redisTemplate.rename(tmpSet, ShangyouHotelApi.REDIS_UPSTREAM_ID_SET);
long ttl = properties.getHotelListCacheMinutes();
redisTemplate.expire(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY, ttl, TimeUnit.MINUTES);
redisTemplate.expire(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY, ttl, TimeUnit.MINUTES);
// ID 集合给差集用,TTL 略长,避免日更空窗
redisTemplate.expire(ShangyouHotelApi.REDIS_UPSTREAM_ID_SET, Math.max(ttl, 24 * 60L), TimeUnit.MINUTES);
}
else {
redisTemplate.delete(tmpHash);
redisTemplate.delete(tmpZset);
redisTemplate.delete(tmpSet);
}
log.info("|上游酒店列表|同步完成|count={}|costMs={}|cacheMinutes={}|store=ZSET+HASH+SET|", count,
System.currentTimeMillis() - t0, properties.getHotelListCacheMinutes());
}
catch (Exception e) {
redisTemplate.delete(tmpHash);
redisTemplate.delete(tmpZset);
redisTemplate.delete(tmpSet);
throw e;
}
finally {
arr.clear();
}
}
/** 强制刷新上游授权列表缓存(含 ID SET) */
public long refreshHotelListCache() {
ensureHotelListCached(true);
Long n = redisTemplate.opsForSet().size(ShangyouHotelApi.REDIS_UPSTREAM_ID_SET);
return n == null ? 0L : n;
}
public boolean isUpstreamHotel(String hotelId) {
if (StrUtil.isBlank(hotelId)) {
return false;
}
Boolean m = redisTemplate.opsForSet().isMember(ShangyouHotelApi.REDIS_UPSTREAM_ID_SET, hotelId);
return Boolean.TRUE.equals(m);
}
public String hotelNameOf(String hotelId) {
Object v = redisTemplate.opsForHash().get(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY, hotelId);
return v == null ? "" : String.valueOf(v);
}
/** 上游酒店精简结构 */
public record HotelBrief(String hotelId, String hotelName) {
}
/** 分页结果 */
public record HotelPage(List<HotelBrief> records, long total) {
}
/**
* 酒店综合信息(静态+房型)。返回完整上游响应(含 code/message/content)。
*/
public JSONObject getHotelComplexListRaw(Collection<String> hotelIds) {
JSONObject body = baseCardBody();
body.put("hotelIdList", hotelIds);
return postBiz(ShangyouHotelApi.HOTEL_COMPLEX, body);
}
public JSONArray getHotelComplexList(Collection<String> hotelIds) {
return asArray(getHotelComplexListRaw(hotelIds).get("content"));
}
/**
* 房价房量。返回完整上游响应(含 code/message/content)。
* QPS 限流在 postBiz 内按次生效(含重试)。
*/
public JSONObject getRoomPriceRaw(String hotelId, String checkInDate, String checkOutDate,
List<String> roomTypeIds) {
JSONObject body = baseCardBody();
body.put("hotelIdList", Collections.singletonList(hotelId));
body.put("checkInDate", checkInDate);
body.put("checkOutDate", checkOutDate);
if (roomTypeIds != null && !roomTypeIds.isEmpty()) {
body.put("roomTypeList", roomTypeIds);
}
List<String> levels = parseMemberLevels();
if (!levels.isEmpty()) {
body.put("memberLevelList", levels);
}
String path = StrUtil.blankToDefault(properties.getRoomPricePath(), "/hotel/getRoomPrice");
if (!path.startsWith("/")) {
path = "/" + path;
}
return postBiz(path, body);
}
/**
* 房价接口限流:本地匀速 + Redis 秒窗硬顶(多实例共享)。
* 对方约定约 20 QPS;配置 price-api-qps≤0 关闭。
*/
private void acquirePriceApiPermit() {
double qps = properties.getPriceApiQps();
if (qps <= 0) {
return;
}
// 1) 本进程匀速:约 1000/qps 毫秒一把
long intervalMs = Math.max(1L, Math.round(1000.0 / qps));
synchronized (this) {
long now = System.currentTimeMillis();
if (priceNextPermitAtMs > now) {
sleepQuiet(priceNextPermitAtMs - now);
now = System.currentTimeMillis();
}
priceNextPermitAtMs = Math.max(now, priceNextPermitAtMs) + intervalMs;
}
// 2) Redis 秒窗:跨实例不超过 ceil(qps)(配置已按实例均分时更稳)
int maxPerSec = Math.max(1, (int) Math.ceil(qps));
for (int i = 0; i < 30; i++) {
long sec = System.currentTimeMillis() / 1000L;
String key = ShangyouHotelApi.REDIS_PRICE_QPS_PREFIX + ":" + sec;
Long n = redisTemplate.opsForValue().increment(key);
if (n != null && n == 1L) {
redisTemplate.expire(key, 3, TimeUnit.SECONDS);
}
if (n == null || n <= maxPerSec) {
return;
}
// 当前秒已满,等到下一秒
long wait = 1000L - (System.currentTimeMillis() % 1000L) + 5L;
sleepQuiet(wait);
}
log.warn("shangyou price QPS wait timeout, continue anyway qps={}", qps);
}
/** 本进程房价下次可发时间 */
private volatile long priceNextPermitAtMs = 0L;
private boolean isRoomPricePath(String path) {
if (StrUtil.isBlank(path)) {
return false;
}
String configured = StrUtil.blankToDefault(properties.getRoomPricePath(), "/hotel/getRoomPrice");
String p = path.startsWith("/") ? path : "/" + path;
String c = configured.startsWith("/") ? configured : "/" + configured;
return p.equalsIgnoreCase(c) || p.toLowerCase().contains("getroomprice");
}
public JSONObject getRoomPrice(String hotelId, String checkInDate, String checkOutDate,
List<String> roomTypeIds) {
JSONObject resp = getRoomPriceRaw(hotelId, checkInDate, checkOutDate, roomTypeIds);
Object content = resp.get("content");
if (content instanceof JSONObject) {
return (JSONObject) content;
}
if (content instanceof JSONArray arr && !arr.isEmpty()) {
return arr.getJSONObject(0);
}
return new JSONObject();
}
public JSONObject checkAvailability(JSONObject body) {
fillCard(body);
JSONObject resp = postBiz(ShangyouHotelApi.CHECK_AVAILABILITY, body);
Object content = resp.get("content");
return content instanceof JSONObject ? (JSONObject) content : new JSONObject();
}
/**
* 下单:成功返回 content;业务码非 200(含 3028)或网络不确定态见 {@link ShangyouOrderCallResult}。
*/
public ShangyouOrderCallResult createOrderRaw(JSONObject body) {
fillCard(body);
if (StrUtil.isBlank(body.getString("bookerId"))) {
body.put("bookerId", StrUtil.blankToDefault(properties.getBookerId(), properties.getCardId()));
}
return postOrderBiz(ShangyouHotelApi.ORDER_ADD, body);
}
public JSONObject createOrder(JSONObject body) {
ShangyouOrderCallResult r = createOrderRaw(body);
if (r.isUncertain()) {
throw new CheckedException("上游下单不确定: " + r.getMessage());
}
if (!r.isBizOk()) {
throw new CheckedException("上游下单失败 code=" + r.getCode() + " msg=" + r.getMessage());
}
return r.getContent() == null ? new JSONObject() : r.getContent();
}
public boolean cancelOrder(String orderId, String hotelId) {
JSONObject body = baseCardBody();
body.put("orderId", orderId);
body.put("hotelId", hotelId);
JSONObject resp = postBiz(ShangyouHotelApi.ORDER_CANCEL, body);
Object content = resp.get("content");
if (content instanceof Boolean) {
return (Boolean) content;
}
return "true".equalsIgnoreCase(String.valueOf(content));
}
public JSONObject queryOrder(String orderId, String hotelId) {
JSONObject body = baseCardBody();
body.put("orderId", orderId);
if (StrUtil.isNotBlank(hotelId)) {
body.put("hotelId", hotelId);
}
JSONObject resp = postBiz(ShangyouHotelApi.ORDER_QUERY, body);
Object content = resp.get("content");
return content instanceof JSONObject ? (JSONObject) content : new JSONObject();
}
public ShangyouOrderCallResult queryByRequestNoRaw(String requestNo) {
JSONObject body = baseCardBody();
body.put("requestNo", requestNo);
return postOrderBiz(ShangyouHotelApi.ORDER_QUERY_BY_REQUEST_NO, body);
}
public JSONObject queryByRequestNo(String requestNo) {
ShangyouOrderCallResult r = queryByRequestNoRaw(requestNo);
if (r.isUncertain()) {
throw new CheckedException("按 requestNo 查询不确定: " + r.getMessage());
}
if (!r.isBizOk()) {
throw new CheckedException("按 requestNo 查询失败 code=" + r.getCode() + " msg=" + r.getMessage());
}
return r.getContent() == null ? new JSONObject() : r.getContent();
}
/**
* 下单/按单号查询专用:不因业务码非 200 抛错(3028 等需补偿);HTTP 5xx/超时标 uncertain。
*/
private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body) {
return postOrderBiz(path, body, false, null);
}
private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body, boolean retried) {
return postOrderBiz(path, body, retried, null);
}
private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body, boolean retried, String invalidToken) {
ensureEnabled();
String token;
try {
token = getAccessToken(retried, invalidToken);
}
catch (Exception e) {
return ShangyouOrderCallResult.uncertain("取 Token 失败: " + e.getMessage(), null);
}
String url = properties.resolveBizBaseUrl() + path;
log.info("shangyou order request path={} retried={} bodyKeys={}", path, retried, body.keySet());
try (HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Bearer " + token)
.header("Accept", "*/*")
.header("Accept-Encoding", "gzip, deflate")
.header("Content-Type", "application/json;charset=UTF-8")
.body(body.toJSONString())
.timeout(properties.getTimeoutMs())
.execute()) {
String respBody = response.body();
int status = response.getStatus();
if (status == 401 || isTokenExpiredBiz(respBody)) {
if (!retried) {
return postOrderBiz(path, body, true, token);
}
return ShangyouOrderCallResult.uncertain("Token 失效且重试仍失败: " + StrUtil.sub(respBody, 0, 300),
status);
}
if (status >= 500 || status == 408 || status == 429) {
return ShangyouOrderCallResult.uncertain("HTTP=" + status + " body=" + StrUtil.sub(respBody, 0, 300),
status);
}
if (!response.isOk()) {
// 4xx 业务侧也可能是确定失败,尽量解析 body
try {
JSONObject resp = JSON.parseObject(respBody);
ShangyouOrderCallResult parsed = ShangyouOrderCallResult.ofResp(resp, status);
if (StrUtil.isBlank(parsed.getCode())) {
return ShangyouOrderCallResult.uncertain("HTTP=" + status + " " + StrUtil.sub(respBody, 0, 300),
status);
}
return parsed;
}
catch (Exception e) {
return ShangyouOrderCallResult.uncertain("HTTP=" + status + " " + StrUtil.sub(respBody, 0, 300),
status);
}
}
JSONObject resp = JSON.parseObject(respBody);
if (isTokenExpiredBiz(resp)) {
if (!retried) {
return postOrderBiz(path, body, true, token);
}
return ShangyouOrderCallResult.uncertain("Token 业务失效且重试仍失败", status);
}
log.info("|上游订单响应|path={}|code={}|message={}|snippet={}|", path, resp.getString("code"),
resp.getString("message"), StrUtil.sub(respBody, 0, 500));
return ShangyouOrderCallResult.ofResp(resp, status);
}
catch (Exception e) {
log.warn("|上游订单调用异常|path={}|msg={}|", path, e.getMessage());
return ShangyouOrderCallResult.uncertain(e.getMessage(), null);
}
}
public String resolveCallbackSecret() {
return StrUtil.blankToDefault(properties.getCallbackSecret(), properties.getCardPassword());
}
private JSONObject baseCardBody() {
JSONObject body = new JSONObject();
fillCard(body);
return body;
}
private void fillCard(JSONObject body) {
body.put("cardId", properties.getCardId());
body.put("cardPassWord", properties.getCardPassword());
}
private List<String> parseMemberLevels() {
if (StrUtil.isBlank(properties.getMemberLevels())) {
return Collections.emptyList();
}
return StrUtil.splitTrim(properties.getMemberLevels(), ',');
}
private JSONObject postBiz(String path, JSONObject body) {
return postBiz(path, body, 0, false, null);
}
/**
* 业务调用。
* <p>
* 文档:业务码 504 = Token 缓存失效,必须重新获取。仅 code=500「繁忙」才按繁忙退避。
* 多线程换 Token 走 Redis 锁,避免并发 oauth 互踢。
* Token 冷却期内遇 504 会快速失败(不睡满冷却),避免单线程回调队列被堵死。
*/
private JSONObject postBiz(String path, JSONObject body, int retryRound, boolean forceToken,
String invalidToken) {
ensureEnabled();
if (isRoomPricePath(path)) {
acquirePriceApiPermit();
}
String token = getAccessToken(forceToken, invalidToken);
String url = properties.resolveBizBaseUrl() + path;
log.info("shangyou request path={} retryRound={} forceToken={} bodyKeys={}", path, retryRound, forceToken,
body.keySet());
try (HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Bearer " + token)
.header("Accept", "*/*")
.header("Accept-Encoding", "gzip, deflate")
.header("Content-Type", "application/json;charset=UTF-8")
.body(body.toJSONString())
.timeout(properties.getTimeoutMs())
.execute()) {
String respBody = response.body();
boolean httpUnauthorized = response.getStatus() == 401;
boolean tokenBizFail = isTokenExpiredBiz(respBody);
if (httpUnauthorized || tokenBizFail) {
if (retryRound < 4) {
// 先看缓存是否已换新:有则立刻用新票重试,避免本线程再 oauth 互踢
String cachedNow = readCachedToken();
boolean alreadyRotated = StrUtil.isNotBlank(cachedNow) && !cachedNow.equals(token);
long waitMs = alreadyRotated ? 50L : (httpUnauthorized ? 200L : 400L);
sleepQuiet(waitMs);
log.warn(
"shangyou 504/token expired, refresh path={} nextRound={} http401={} alreadyRotated={} snippet={}",
path, retryRound + 1, httpUnauthorized, alreadyRotated, StrUtil.sub(respBody, 0, 200));
return postBiz(path, body, retryRound + 1, true, token);
}
throw new CheckedException("上游 Token 失效且重试仍失败 path=" + path + " body=" + respBody);
}
if (!response.isOk()) {
throw new CheckedException("上游调用失败 HTTP=" + response.getStatus() + " path=" + path
+ " body=" + respBody);
}
JSONObject resp = JSON.parseObject(respBody);
if (isTokenExpiredBiz(resp)) {
if (retryRound < 4) {
String cachedNow = readCachedToken();
boolean alreadyRotated = StrUtil.isNotBlank(cachedNow) && !cachedNow.equals(token);
sleepQuiet(alreadyRotated ? 50L : 400L);
log.warn("shangyou biz 504/token expired, refresh path={} nextRound={} alreadyRotated={} msg={}",
path, retryRound + 1, alreadyRotated, resp.getString("message"));
return postBiz(path, body, retryRound + 1, true, token);
}
throw new CheckedException("上游 Token 失效且重试仍失败 path=" + path + " body=" + respBody);
}
// 上游瞬时繁忙(非 504):短暂退避后重试,不刷新 Token
if (isUpstreamBusy(resp) && retryRound < 4) {
long waitMs = switch (retryRound) {
case 0 -> 800L;
case 1 -> 1500L;
case 2 -> 3000L;
default -> 5000L;
};
sleepQuiet(waitMs);
log.warn("shangyou busy, retry path={} nextRound={} msg={}", path, retryRound + 1,
resp.getString("message"));
return postBiz(path, body, retryRound + 1, false, null);
}
if (isUpstreamBusy(resp)) {
throw new CheckedException("上游应用程序繁忙,请稍后再试 path=" + path);
}
ensureBizOk(resp, path);
// 酒店列表很大,只打数量,避免日志/字符串截取拖慢
if (ShangyouHotelApi.HOTEL_LIST.equals(path)) {
Object content = resp.get("content");
int size = content instanceof JSONArray ? ((JSONArray) content).size() : (content == null ? 0 : 1);
log.info("|上游酒店响应|path={}|code={}|message={}|listSize={}|", path, resp.getString("code"),
resp.getString("message"), size);
}
else {
log.info("|上游酒店响应|path={}|code={}|message={}|contentNull={}|snippet={}|", path,
resp.getString("code"), resp.getString("message"), resp.get("content") == null,
StrUtil.sub(respBody, 0, 800));
}
return resp;
}
}
private static boolean isTokenExpiredBiz(String respBody) {
if (StrUtil.isBlank(respBody)) {
return false;
}
try {
return isTokenExpiredBiz(JSON.parseObject(respBody));
}
catch (Exception e) {
return false;
}
}
/** 文档:业务错误码 504 表示 token 缓存失效,需重新获取 */
private static boolean isTokenExpiredBiz(JSONObject resp) {
if (resp == null) {
return false;
}
String code = firstNonBlank(resp.getString("code"), resp.getString("Code"));
if ("504".equals(code)) {
return true;
}
String msg = firstNonBlank(resp.getString("message"), resp.getString("msg"));
return StrUtil.containsIgnoreCase(msg, "token")
&& (StrUtil.containsIgnoreCase(msg, "重新获取") || StrUtil.containsIgnoreCase(msg, "校验失败")
|| StrUtil.containsIgnoreCase(msg, "失效") || StrUtil.containsIgnoreCase(msg, "为空"));
}
/** 上游瞬时繁忙(滚价高峰常见) */
private static boolean isUpstreamBusy(JSONObject resp) {
if (resp == null) {
return false;
}
String code = firstNonBlank(resp.getString("code"), resp.getString("Code"));
String msg = firstNonBlank(resp.getString("message"), resp.getString("msg"));
if ("500".equals(code) && StrUtil.containsIgnoreCase(msg, "繁忙")) {
return true;
}
return StrUtil.containsIgnoreCase(msg, "请稍后再试") || StrUtil.containsIgnoreCase(msg, "稍候再试");
}
private static void sleepQuiet(long ms) {
if (ms <= 0) {
return;
}
try {
Thread.sleep(ms);
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void ensureBizOk(JSONObject resp, String path) {
if (resp == null) {
throw new CheckedException("上游返回空 path=" + path);
}
String code = firstNonBlank(resp.getString("code"), resp.getString("Code"));
if (StrUtil.isBlank(code)) {
return;
}
if (!"200".equals(code) && !"0".equals(code) && !"OK".equalsIgnoreCase(code)) {
String msg = firstNonBlank(resp.getString("message"), resp.getString("msg"), resp.toJSONString());
throw new CheckedException("上游业务失败 path=" + path + " code=" + code + " msg=" + msg);
}
}
private static JSONArray asArray(Object content) {
if (content instanceof JSONArray) {
return (JSONArray) content;
}
if (content instanceof JSONObject) {
JSONArray arr = new JSONArray();
arr.add(content);
return arr;
}
return new JSONArray();
}
private static String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String v : values) {
if (StrUtil.isNotBlank(v)) {
return v;
}
}
return null;
}
}
@@ -0,0 +1,80 @@
package com.bsd.pjcloud.supermarket.integration.shangyou;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import lombok.Data;
/**
* 上游下单/按 requestNo 查询的调用结果(含不确定态,用于补偿)。
*/
@Data
public class ShangyouOrderCallResult {
/** HTTP/网络超时、5xx 等,未拿到确定业务结果 */
private boolean uncertain;
/** 业务码,如 200 / 3028 */
private String code;
private String message;
/** content 对象(orderId/bookStatus 等) */
private JSONObject content;
private JSONObject raw;
private Integer httpStatus;
private String exceptionMsg;
public boolean isBizOk() {
return !uncertain && ("200".equals(code) || "0".equals(code) || "OK".equalsIgnoreCase(code));
}
/** 总对总付款失败,文档要求走补偿 */
public boolean isPayFail3028() {
return "3028".equals(code);
}
public String orderId() {
return content == null ? null : content.getString("orderId");
}
public String bookStatus() {
return content == null ? null : content.getString("bookStatus");
}
public String payStatus() {
return content == null ? null : content.getString("payStatus");
}
public static ShangyouOrderCallResult uncertain(String msg, Integer httpStatus) {
ShangyouOrderCallResult r = new ShangyouOrderCallResult();
r.setUncertain(true);
r.setMessage(msg);
r.setHttpStatus(httpStatus);
r.setExceptionMsg(msg);
return r;
}
public static ShangyouOrderCallResult ofResp(JSONObject resp, Integer httpStatus) {
ShangyouOrderCallResult r = new ShangyouOrderCallResult();
r.setUncertain(false);
r.setRaw(resp);
r.setHttpStatus(httpStatus);
if (resp != null) {
r.setCode(StrUtil.blankToDefault(resp.getString("code"), resp.getString("Code")));
r.setMessage(StrUtil.blankToDefault(resp.getString("message"), resp.getString("msg")));
Object content = resp.get("content");
if (content instanceof JSONObject) {
r.setContent((JSONObject) content);
}
else if (content != null) {
JSONObject wrap = new JSONObject();
wrap.put("value", content);
r.setContent(wrap);
}
}
return r;
}
}
@@ -0,0 +1,80 @@
package com.bsd.pjcloud.supermarket.integration.shangyou;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 订单补偿调度:按文档在下单后第 3/5/10 分钟 queryByRequestNo(必要时重试 order/add)。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ShangyouOrderCompensateWorker implements ApplicationRunner {
private final ShangyouHotelProperties properties;
private final SmHotelOrderService orderService;
private final AtomicBoolean running = new AtomicBoolean(true);
private Thread worker;
@Override
public void run(ApplicationArguments args) {
if (!properties.isEnabled()) {
log.info("|订单补偿|上游未启用,Worker 不启动");
return;
}
worker = new Thread(this::loop, "shangyou-order-compensate");
worker.setDaemon(true);
worker.start();
log.info("|订单补偿|Worker 已启动(轮询 next_compensate_time)");
}
private void loop() {
while (running.get()) {
try {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
int n = TenantBroker.applyAs(tenantId, tid -> orderService.processDueCompensate(20));
if (n > 0) {
log.info("|订单补偿|本轮处理 {} 单", n);
}
Thread.sleep(n > 0 ? 3000L : 15000L);
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
catch (Exception e) {
if (!running.get()) {
break;
}
log.error("|订单补偿|调度异常", e);
try {
Thread.sleep(10000L);
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
@PreDestroy
public void shutdown() {
running.set(false);
if (worker != null) {
worker.interrupt();
}
}
}
@@ -0,0 +1,58 @@
package com.bsd.pjcloud.supermarket.job;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* YAML 可配 Cron 的日更入口(与 XXL Job 二选一即可)。
* <p>
* 开关:{@code shangyou-hotel.spring-schedule-enabled}<br>
* 时间:{@code hotel-list-daily-cron} / {@code price-roll-daily-cron}
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmHotelShangyouSpringSchedule {
private final ShangyouHotelProperties properties;
private final SmHotelSyncService syncService;
@Scheduled(cron = "${shangyou-hotel.hotel-list-daily-cron:0 0 2 * * ?}")
public void scheduledListSync() {
if (!properties.isSpringScheduleEnabled()) {
return;
}
if (!properties.isHotelListDailySyncEnabled()) {
return;
}
try {
log.info("|酒店日更|Spring Cron 触发列表同步|cron={}|", properties.getHotelListDailyCron());
syncService.jobSyncHotelList("CRON");
}
catch (Exception e) {
log.error("|酒店日更|Spring 列表同步异常", e);
}
}
@Scheduled(cron = "${shangyou-hotel.price-roll-daily-cron:0 0 3 * * ?}")
public void scheduledPriceRoll() {
if (!properties.isSpringScheduleEnabled()) {
return;
}
if (!properties.isPriceRollDailyEnabled()) {
return;
}
try {
log.info("|酒店日更|Spring Cron 触发滚价|cron={}|", properties.getPriceRollDailyCron());
syncService.jobRollDailyPrice("CRON");
}
catch (Exception e) {
log.error("|酒店日更|Spring 滚价异常", e);
}
}
}
@@ -0,0 +1,56 @@
package com.bsd.pjcloud.supermarket.job;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 上游酒店日更:
* <ul>
* <li>{@link #HANDLER_LIST_SYNC}:getHotelList + Redis 差集 + 可选自动导入新增</li>
* <li>{@link #HANDLER_PRICE_ROLL}:对本地酒店只拉 today+offset 一天房价</li>
* </ul>
* 频率在 XXL-JOB 控制台配 Cron;开关与参数在 shangyou-hotel.*。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelShangyouSyncJob {
public static final String HANDLER_LIST_SYNC = "smHotelListSyncJobHandler";
public static final String HANDLER_PRICE_ROLL = "smHotelPriceRollJobHandler";
private final SmHotelSyncService syncService;
@XxlJob(HANDLER_LIST_SYNC)
public void smHotelListSyncJobHandler() {
try {
Map<String, Object> r = syncService.jobSyncHotelList();
XxlJobHelper.log("hotel list sync: " + r);
XxlJobHelper.handleSuccess(String.valueOf(r));
}
catch (Exception e) {
log.error("smHotelListSyncJobHandler 异常", e);
XxlJobHelper.handleFail(e.getMessage());
}
}
@XxlJob(HANDLER_PRICE_ROLL)
public void smHotelPriceRollJobHandler() {
try {
Map<String, Object> r = syncService.jobRollDailyPrice();
XxlJobHelper.log("hotel price roll: " + r);
XxlJobHelper.handleSuccess(String.valueOf(r));
}
catch (Exception e) {
log.error("smHotelPriceRollJobHandler 异常", e);
XxlJobHelper.handleFail(e.getMessage());
}
}
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelCallbackTaskEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelCallbackTaskMapper extends PjcloudBaseMapper<SmHotelCallbackTaskEntity> {
}
@@ -0,0 +1,49 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@Mapper
public interface SmHotelDailyMapper extends PjcloudBaseMapper<SmHotelDailyEntity> {
/**
* 物理删除未来日历。唯一键 uk_product_date 不含 del_flag,软删后再插入会冲突。
*/
@Delete("DELETE FROM canteen_hotel_daily WHERE hotel_id = #{hotelId} AND stay_date >= #{fromDate}")
int physicalDeleteFromDate(@Param("hotelId") Long hotelId, @Param("fromDate") LocalDate fromDate);
int insertBatch(@Param("list") List<SmHotelDailyEntity> list);
/**
* 按底价重算日历卖价:sell = ROUND(cost * rate, 2)。不改订单。
*/
@Update("""
UPDATE canteen_hotel_daily
SET sell_price = ROUND(cost_price * #{rate}, 2),
update_time = NOW()
WHERE del_flag = '0'
AND cost_price IS NOT NULL
""")
int resyncSellPriceByCost(@Param("rate") BigDecimal rate);
@Select("""
<script>
SELECT hotel_id AS hotelId, COUNT(1) AS cnt FROM canteen_hotel_daily
WHERE del_flag = '0' AND hotel_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY hotel_id
</script>
""")
List<Map<String, Object>> countGroupByHotelIds(@Param("ids") Collection<Long> ids);
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelDownstreamAppMapper extends PjcloudBaseMapper<SmHotelDownstreamAppEntity> {
}
@@ -0,0 +1,14 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SmHotelMapper extends PjcloudBaseMapper<SmHotelEntity> {
int insertBatch(@Param("list") List<SmHotelEntity> list);
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelOrderMapper extends PjcloudBaseMapper<SmHotelOrderEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelPriceConfigMapper extends PjcloudBaseMapper<SmHotelPriceConfigEntity> {
}
@@ -0,0 +1,41 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@Mapper
public interface SmHotelProductMapper extends PjcloudBaseMapper<SmHotelProductEntity> {
int insertBatch(@Param("list") List<SmHotelProductEntity> list);
/**
* 按底价重算卖价:sell = ROUND(cost * rate, 2)。不改订单。
*/
@Update("""
UPDATE canteen_hotel_product
SET sell_price = ROUND(cost_price * #{rate}, 2),
update_time = NOW()
WHERE del_flag = '0'
AND cost_price IS NOT NULL
""")
int resyncSellPriceByCost(@Param("rate") BigDecimal rate);
@Select("""
<script>
SELECT hotel_id AS hotelId, COUNT(1) AS cnt FROM canteen_hotel_product
WHERE del_flag = '0' AND hotel_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY hotel_id
</script>
""")
List<Map<String, Object>> countGroupByHotelIds(@Param("ids") Collection<Long> ids);
}
@@ -0,0 +1,27 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@Mapper
public interface SmHotelRoomMapper extends PjcloudBaseMapper<SmHotelRoomEntity> {
int insertBatch(@Param("list") List<SmHotelRoomEntity> list);
@Select("""
<script>
SELECT hotel_id AS hotelId, COUNT(1) AS cnt FROM canteen_hotel_room
WHERE del_flag = '0' AND hotel_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY hotel_id
</script>
""")
List<Map<String, Object>> countGroupByHotelIds(@Param("ids") Collection<Long> ids);
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelSyncLogMapper extends PjcloudBaseMapper<SmHotelSyncLogEntity> {
}
@@ -0,0 +1,49 @@
package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelCallbackTaskEntity;
import com.bsd.pjcloud.common.core.util.R;
import java.util.Map;
/**
* 上游房价/房量回调:落库入队 + 异步拉取 + 后台查询。
*/
public interface SmHotelCallbackTaskService {
/**
* 验签通过后调用:只落库 + 入 Redis 队列,不做上游拉取。
* @return Shangyou 格式 {code,message}
*/
Map<String, Object> enqueueFromNotify(String notifyType, Map<String, Object> body, String time, String sign);
/** 后台分页(可按回调时间 createTime 区间筛选) */
Page<SmHotelCallbackTaskEntity> pageTasks(Page<SmHotelCallbackTaskEntity> page,
SmHotelCallbackTaskEntity query, String createTimeBegin, String createTimeEnd);
SmHotelCallbackTaskEntity getDetail(Long id);
/** 失败/跳过任务重新入队 */
R<Boolean> retry(Long id);
/** 删除单条回调任务 */
R<Boolean> removeTask(Long id);
/**
* 按创建时间区间删除(含边界)。begin/end 都空则拒绝。
* @return 删除条数
*/
R<Integer> removeByCreateTimeRange(String createTimeBegin, String createTimeEnd);
/** 清空全部回调任务,并清空 Redis 消费队列 */
R<Integer> clearAllTasks();
/** 消费一条队列任务(Worker 调用);返回 true=本次成功/跳过,false=失败或待再试 */
boolean processQueuedTask(Long taskId);
/** 启动时把滞留 PENDING / 超时 RUNNING 重新入队 */
int requeueStalePending();
/** 仅把超时仍为 RUNNING 的任务重置入队(Worker 空闲时定期调用) */
int recoverStaleRunning();
}
@@ -0,0 +1,61 @@
package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.supermarket.vo.SmHotelDetailVO;
import com.bsd.pjcloud.common.core.util.R;
import java.util.List;
import java.util.Map;
/**
* 上游拉取落库 + 加价后推下游。
*/
public interface SmHotelMidService {
/** 从上游拉取房型/产品并按加价生成日历,落本地库 */
R<Map<String, Object>> pullFromUpstream(Long hotelId);
/**
* 仅调用 getHotelComplexList 落房型(不拉房价),用于单独验证综合信息接口。
*/
R<Map<String, Object>> pullRoomsFromUpstream(Long hotelId);
/**
* 仅拉取房价样例(默认今天 1 天 getRoomPrice),用于单独验证房价接口。
*/
R<Map<String, Object>> pullPriceFromUpstream(Long hotelId);
/**
* 仅拉取产品样例(默认今天 1 天 getRoomPrice 落产品),用于单独验证产品解析落库。
*/
R<Map<String, Object>> pullProductsFromUpstream(Long hotelId);
/**
* 仅拉取指定日期区间房价(含房型综合若本地无房型),用于「滚一天」;区间建议 1 天,最长按上游 31 天切片。
*/
R<Map<String, Object>> pullPriceRange(Long hotelId, java.time.LocalDate startInclusive,
java.time.LocalDate endExclusive);
/** 将本地酒店+房型+产品+日历推送给下游销售方 */
R<Map<String, Object>> pushToDownstream(Long hotelId);
R<Map<String, Object>> pullAndPush(Long hotelId);
R<Map<String, Object>> pullAndPushBatch(List<Long> hotelIds);
/** 酒店详情:主数据 + 已拉取的房型/产品/日历预览 */
R<SmHotelDetailVO> getHotelDetail(Long hotelId);
/** 日历分页 */
R<Page<SmHotelDailyEntity>> pageDaily(Long hotelId, Page<SmHotelDailyEntity> page, String productCode);
/** 查询当前上游账号已授权酒店列表(getHotelList,支持关键字与分页) */
R<Map<String, Object>> listAuthorizedHotels(String keyword, long current, long size, boolean refresh);
/** 重建本地 hotel_code Redis SET(分批,防 OOM);供差集与列表标记复用 */
long rebuildLocalHotelCodeSet();
/** 将选中的上游 hotelId 写入本地(不存在则新增) */
R<Map<String, Object>> importAuthorizedHotels(List<Map<String, Object>> hotels);
}
@@ -0,0 +1,43 @@
package com.bsd.pjcloud.supermarket.service;
import cn.hutool.json.JSONObject;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.common.core.util.R;
import java.util.Map;
public interface SmHotelOrderService {
Page<SmHotelOrderEntity> pageOrders(Page<SmHotelOrderEntity> page, SmHotelOrderEntity query);
SmHotelOrderEntity getDetail(Long id);
/** 下游回传下单 → 可定校验 → 上游创建 → 必要时挂补偿 */
R<JSONObject> createFromDownstream(String appKey, Map<String, Object> body);
/**
* PC 后台试下单(不依赖下游 appKey,channelNo 可自动生成)。
* 必填:hotelId、productCode、checkInDate、checkOutDate、contactName、contactPhone
*/
R<JSONObject> createByAdmin(Map<String, Object> body);
/** 下游回传取消 → 调上游取消 */
R<JSONObject> cancelFromDownstream(String appKey, Map<String, Object> body);
R<JSONObject> queryFromDownstream(String appKey, Map<String, Object> body);
/** PC 后台取消 */
R<Boolean> cancelByAdmin(Long id, String reason);
/** Worker:扫描到期补偿任务 */
int processDueCompensate(int limit);
/** PC 手工立刻补偿查询 */
R<Boolean> compensateNow(Long id);
/**
* 预订单状态变更回调落库(验签后由上层调用)。
*/
Map<String, Object> handleOrderStatusNotify(Map<String, Object> body);
}
@@ -0,0 +1,29 @@
package com.bsd.pjcloud.supermarket.service;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
import com.bsd.pjcloud.common.core.util.R;
import java.math.BigDecimal;
import java.util.Map;
public interface SmHotelPriceConfigService {
R<SmHotelPriceConfigEntity> getCurrent();
R<Boolean> saveOrUpdateConfig(SmHotelPriceConfigEntity entity);
/** 当前售价调整百分比(正加价/负折扣),未配置返回 0 */
BigDecimal currentMarkupPercent();
/** 底价按当前配置计算卖价:cost × (1 + percent/100) */
BigDecimal applyMarkup(BigDecimal costPrice);
/** 用指定百分比计算卖价(不读库) */
BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent);
/**
* 按当前配置重算全部产品/日历卖价(仅 sell_price,不动订单)。
* @return productUpdated / dailyUpdated / markupPercent
*/
R<Map<String, Object>> resyncAllSellPrices();
}
@@ -0,0 +1,26 @@
package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO;
import com.bsd.pjcloud.common.core.util.R;
import org.springframework.web.multipart.MultipartFile;
public interface SmHotelService extends IService<SmHotelEntity> {
Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page, LambdaQueryWrapper<SmHotelEntity> wrapper);
Boolean saveHotel(SmHotelEntity entity);
Boolean updateHotel(SmHotelEntity entity);
Boolean removeHotels(Long[] ids);
/** 提交异步导入,立刻返回 taskId(status=RUNNING) */
R<SmHotelImportResultVO> startImport(MultipartFile file);
/** 查询导入任务进度 */
R<SmHotelImportResultVO> getImportStatus(String taskId);
}
@@ -0,0 +1,71 @@
package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity;
import com.bsd.pjcloud.common.core.util.R;
import java.util.Map;
/**
* 上游酒店列表日更、Redis 差集、滚一天房价(异步 + 进度日志)。
*/
public interface SmHotelSyncService {
/**
* 提交列表同步(立刻返回 logId,后台异步执行)。
* @param autoImport 是否自动导入新增
* @param triggerType MANUAL / XXL / CRON
*/
R<SmHotelSyncLogEntity> startListSyncAsync(boolean autoImport, String triggerType);
/** 查询同步任务进度/结果 */
R<SmHotelSyncLogEntity> getSyncLog(Long id);
/** 同步日志分页 */
Page<SmHotelSyncLogEntity> pageSyncLogs(Page<SmHotelSyncLogEntity> page, SmHotelSyncLogEntity query);
/** 分页查看差集:type=new|offline */
R<Map<String, Object>> pageHotelDiff(String type, long current, long size);
/** 把差集「新增」全部/限额导入本地主数据 */
R<Map<String, Object>> importDiffNewHotels(Integer limit);
/** 提交滚价异步任务 */
R<SmHotelSyncLogEntity> startPriceRollAsync(String triggerType);
/**
* 异步拉取全部启用酒店详情(房型+产品+未来 price-days 房价)。
* @param limit 最多处理家数,null/≤0 用配置 detail-pull-max-hotels(0=不限)
*/
R<SmHotelSyncLogEntity> startDetailPullAsync(Integer limit, String triggerType);
/**
* 根据某次同步日志的 failDetail,只重试失败酒店(支持 DETAIL_PULL / PRICE_ROLL)。
*/
R<SmHotelSyncLogEntity> startRetryFailedAsync(Long sourceLogId);
/** 人工终止一条仍显示进行中的同步日志(死任务) */
R<Boolean> abortSyncLog(Long id);
/**
* 将长时间无进度更新的 RUNNING 日志标为失败,避免挡后续任务。
* @return 清理条数
*/
int abortStaleRunningSyncLogs();
/** 兼容旧同步入口(内部转异步) */
R<Map<String, Object>> syncHotelListAndDiff(boolean autoImport);
/** 当前日更时间配置(给前端展示) */
Map<String, Object> getSyncSchedule();
/** XXL / 定时:列表日更 */
Map<String, Object> jobSyncHotelList();
Map<String, Object> jobSyncHotelList(String triggerType);
/** XXL / 定时:滚价 */
Map<String, Object> jobRollDailyPrice();
Map<String, Object> jobRollDailyPrice(String triggerType);
}
@@ -0,0 +1,45 @@
package com.bsd.pjcloud.supermarket.service;
import cn.hutool.json.JSONObject;
import com.bsd.pjcloud.common.core.util.R;
import java.util.List;
import java.util.Map;
/**
* 玉编电云酒店上游对接(查询/同步/下单代理)。
*/
public interface SmHotelUpstreamService {
/** 按本地酒店主键拉取上游综合信息并回写名称等 */
R<JSONObject> syncHotelInfo(Long hotelId);
/** 批量同步 */
R<Map<String, Object>> syncHotelInfoBatch(List<Long> hotelIds);
R<JSONObject> hotelInfo(String groupCode, String hotelCode);
R<JSONObject> roomTypeList(String groupCode, String hotelCode);
R<JSONObject> productList(String groupCode, String hotelCode, String channelCode);
R<JSONObject> orderCreate(Map<String, Object> body);
R<JSONObject> orderCancel(Map<String, Object> body);
R<JSONObject> orderGetById(Map<String, Object> body);
R<JSONObject> orderCheck(Map<String, Object> body);
/** 接收上游变更通知 */
R<JSONObject> handleNotify(Map<String, Object> body);
/** 接收上游变更通知(Header: time / sign=MD5(Secret+time)) */
R<JSONObject> handleNotify(Map<String, Object> body, String time, String sign);
/**
* 玉编电云房价/房量回调:验签后落库入队,立即返回 {code,message};异步再拉取。
* @param notifyType PRICE / INVENTORY
*/
Map<String, Object> handleShangyouNotify(String notifyType, Map<String, Object> body, String time, String sign);
}
@@ -0,0 +1,518 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.entity.SmHotelCallbackTaskEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouCallbackSignHelper;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelApi;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import com.bsd.pjcloud.supermarket.mapper.SmHotelCallbackTaskMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelCallbackTaskService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 回调只验签落库入队;真正拉取在 Worker 异步执行。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskService {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_RUNNING = "RUNNING";
public static final String STATUS_SUCCESS = "SUCCESS";
public static final String STATUS_FAILED = "FAILED";
public static final String STATUS_SKIPPED = "SKIPPED";
private final ShangyouHotelProperties properties;
private final ShangyouHotelClient client;
private final SmHotelCallbackTaskMapper taskMapper;
private final SmHotelMapper hotelMapper;
private final SmHotelMidService midService;
private final SmHotelOrderService orderService;
private final StringRedisTemplate redisTemplate;
@Override
public Map<String, Object> enqueueFromNotify(String notifyType, Map<String, Object> body, String time,
String sign) {
Map<String, Object> ok = successResp();
log.info("|上游酒店通知|入队|type={}|time={}|hotelId={}|body={}|", notifyType, time,
body == null ? null : body.get("hotelId"), body);
if (StrUtil.isBlank(time) || StrUtil.isBlank(sign)) {
return failResp("401", "缺少 time/sign");
}
if (!ShangyouCallbackSignHelper.verify(client.resolveCallbackSecret(), time, sign)) {
return failResp("401", "验签失败");
}
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
try {
TenantBroker.runAs(tenantId, tid -> {
SmHotelCallbackTaskEntity task = buildTask(notifyType, body, time, tid);
taskMapper.insert(task);
if (properties.isCallbackQueueEnabled()) {
enqueueTaskId(task.getId(), task.getNotifyType());
}
log.info("|上游酒店通知|已入队|taskId={}|type={}|priority={}|hotelCode={}|hotelId={}|status={}|",
task.getId(), task.getNotifyType(),
isHighPriority(task.getNotifyType()) ? "HIGH" : "NORMAL", task.getHotelCode(),
task.getHotelId(), task.getStatus());
});
}
catch (Exception e) {
log.error("|上游酒店通知|入队失败|type={}|", notifyType, e);
// 仍返回 200,避免对方因落库抖动反复重推;失败日志可人工补救
ok.put("message", "成功!");
}
return ok;
}
@Override
public Page<SmHotelCallbackTaskEntity> pageTasks(Page<SmHotelCallbackTaskEntity> page,
SmHotelCallbackTaskEntity query, String createTimeBegin, String createTimeEnd) {
LocalDateTime begin = parseDateTime(createTimeBegin, true);
LocalDateTime end = parseDateTime(createTimeEnd, false);
return taskMapper.selectPage(page, Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.eq(StrUtil.isNotBlank(query.getNotifyType()), SmHotelCallbackTaskEntity::getNotifyType,
query.getNotifyType())
.eq(StrUtil.isNotBlank(query.getStatus()), SmHotelCallbackTaskEntity::getStatus, query.getStatus())
.like(StrUtil.isNotBlank(query.getHotelCode()), SmHotelCallbackTaskEntity::getHotelCode,
query.getHotelCode())
.like(StrUtil.isNotBlank(query.getHotelName()), SmHotelCallbackTaskEntity::getHotelName,
query.getHotelName())
.ge(begin != null, SmHotelCallbackTaskEntity::getCreateTime, begin)
.le(end != null, SmHotelCallbackTaskEntity::getCreateTime, end)
.orderByDesc(SmHotelCallbackTaskEntity::getCreateTime));
}
@Override
public SmHotelCallbackTaskEntity getDetail(Long id) {
return taskMapper.selectById(id);
}
@Override
public R<Boolean> retry(Long id) {
SmHotelCallbackTaskEntity task = taskMapper.selectById(id);
if (task == null) {
return R.failed("任务不存在");
}
if (STATUS_RUNNING.equals(task.getStatus()) || STATUS_PENDING.equals(task.getStatus())) {
return R.failed("任务处理中或已在队列,请稍候");
}
if (task.getHotelId() == null && StrUtil.isNotBlank(task.getHotelCode())) {
SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, task.getHotelCode())
.last("limit 1"));
if (hotel != null) {
task.setHotelId(hotel.getId());
task.setHotelName(hotel.getHotelName());
}
}
task.setStatus(STATUS_PENDING);
task.setResultMsg("人工重试入队");
task.setFinishTime(null);
task.setCostMs(null);
task.setUpdateTime(LocalDateTime.now());
taskMapper.updateById(task);
enqueueTaskId(task.getId(), task.getNotifyType());
return R.ok(Boolean.TRUE, isHighPriority(task.getNotifyType()) ? "已重新入队(高优先级)" : "已重新入队");
}
@Override
public R<Boolean> removeTask(Long id) {
if (id == null) {
return R.failed("任务ID不能为空");
}
SmHotelCallbackTaskEntity task = taskMapper.selectById(id);
if (task == null) {
return R.failed("任务不存在");
}
if (STATUS_RUNNING.equals(task.getStatus())) {
return R.failed("任务处理中,请稍后再删");
}
taskMapper.deleteById(id);
return R.ok(Boolean.TRUE, "已删除");
}
@Override
public R<Integer> removeByCreateTimeRange(String createTimeBegin, String createTimeEnd) {
LocalDateTime begin = parseDateTime(createTimeBegin, true);
LocalDateTime end = parseDateTime(createTimeEnd, false);
if (begin == null && end == null) {
return R.failed("请指定开始或结束时间");
}
if (begin != null && end != null && end.isBefore(begin)) {
return R.failed("结束时间不能早于开始时间");
}
// 不删 RUNNING,避免删掉正在消费的任务导致状态错乱
var qw = Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.ne(SmHotelCallbackTaskEntity::getStatus, STATUS_RUNNING);
if (begin != null) {
qw.ge(SmHotelCallbackTaskEntity::getCreateTime, begin);
}
if (end != null) {
qw.le(SmHotelCallbackTaskEntity::getCreateTime, end);
}
Long cnt = taskMapper.selectCount(qw);
int n = taskMapper.delete(qw);
log.info("|回调任务|按创建时间删除|begin={}|end={}|deleted={}|matched={}|", begin, end, n, cnt);
return R.ok(n, "已删除 " + n + " 条");
}
@Override
public R<Integer> clearAllTasks() {
Long cnt = taskMapper.selectCount(Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.ne(SmHotelCallbackTaskEntity::getStatus, STATUS_RUNNING));
int n = taskMapper.delete(Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.ne(SmHotelCallbackTaskEntity::getStatus, STATUS_RUNNING));
// 清空消费队列(含高优),避免旧 taskId 反复弹出
Long qLen = redisTemplate.opsForList().size(ShangyouHotelApi.REDIS_CALLBACK_QUEUE);
Long qHigh = redisTemplate.opsForList().size(ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH);
redisTemplate.delete(ShangyouHotelApi.REDIS_CALLBACK_QUEUE);
redisTemplate.delete(ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH);
log.warn("|回调任务|清空全部|deleted={}|queueCleared≈{}/high≈{}|", n, qLen, qHigh);
return R.ok(n, "已清空 " + n + " 条任务,并清理队列"
+ (cnt != null && cnt > n ? "(处理中任务未删)" : ""));
}
/** 支持 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss;日期无时分秒时 begin=00:00:00、end=23:59:59 */
private static LocalDateTime parseDateTime(String raw, boolean beginOfDay) {
if (StrUtil.isBlank(raw)) {
return null;
}
String s = raw.trim().replace('T', ' ');
try {
if (s.length() <= 10) {
LocalDate d = LocalDate.parse(s.substring(0, 10), DateTimeFormatter.ISO_LOCAL_DATE);
return beginOfDay ? d.atStartOfDay() : d.atTime(23, 59, 59);
}
if (s.length() == 16) {
return LocalDateTime.parse(s, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
}
return LocalDateTime.parse(s.substring(0, Math.min(19, s.length())),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
catch (Exception e) {
return null;
}
}
@Override
public boolean processQueuedTask(Long taskId) {
if (taskId == null) {
return true;
}
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
Boolean ok = TenantBroker.applyAs(tenantId, tid -> doProcess(taskId));
return Boolean.TRUE.equals(ok);
}
@Override
public int requeueStalePending() {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> {
int n = 0;
// 1) PENDING:按创建时间 FIFO;ORDER_STATUS 进高优队列
var pending = taskMapper.selectList(Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.eq(SmHotelCallbackTaskEntity::getStatus, STATUS_PENDING)
.orderByAsc(SmHotelCallbackTaskEntity::getCreateTime)
.last("limit 200"));
for (SmHotelCallbackTaskEntity t : pending) {
enqueueTaskId(t.getId(), t.getNotifyType());
n++;
}
// 2) 卡住的 RUNNING
n += resetStaleRunningLocked();
if (n > 0) {
log.info("|回调队列|重启恢复入队|pendingAndStaleRunning={}|", n);
}
return n;
});
}
@Override
public int recoverStaleRunning() {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> resetStaleRunningLocked());
}
/** 超时 RUNNING → PENDING 并入队尾;已达最大次数则直接 FAILED,不再堵队列 */
private int resetStaleRunningLocked() {
// 单日区间拉取正常应秒级完成;2 分钟仍 RUNNING 视为卡死(Token 冷却/进程挂起等)
LocalDateTime staleBefore = LocalDateTime.now().minusMinutes(2);
int max = Math.max(1, properties.getCallbackMaxAttempts());
var running = taskMapper.selectList(Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.eq(SmHotelCallbackTaskEntity::getStatus, STATUS_RUNNING)
.and(w -> w.lt(SmHotelCallbackTaskEntity::getStartProcessTime, staleBefore)
.or()
.isNull(SmHotelCallbackTaskEntity::getStartProcessTime)
.lt(SmHotelCallbackTaskEntity::getUpdateTime, staleBefore))
.orderByAsc(SmHotelCallbackTaskEntity::getUpdateTime)
.last("limit 100"));
int n = 0;
for (SmHotelCallbackTaskEntity t : running) {
int attempts = t.getAttemptCount() == null ? 0 : t.getAttemptCount();
t.setUpdateTime(LocalDateTime.now());
if (attempts >= max) {
t.setStatus(STATUS_FAILED);
t.setFinishTime(LocalDateTime.now());
t.setResultMsg("超时仍 RUNNING 且已达最大次数(" + attempts + "/" + max + "),自动失败以免堵队列");
taskMapper.updateById(t);
n++;
continue;
}
t.setStatus(STATUS_PENDING);
t.setResultMsg("超时 RUNNING 已重置为 PENDING 并重新入队");
taskMapper.updateById(t);
enqueueTaskId(t.getId(), t.getNotifyType());
n++;
}
if (n > 0) {
log.warn("|回调队列|恢复超时 RUNNING|count={}|", n);
}
return n;
}
/** @return true 成功或跳过;false 失败/待重试 */
private boolean doProcess(Long taskId) {
SmHotelCallbackTaskEntity task = taskMapper.selectById(taskId);
if (task == null) {
log.warn("|回调队列|任务不存在|taskId={}|", taskId);
return true;
}
if (!STATUS_PENDING.equals(task.getStatus())) {
log.info("|回调队列|跳过非 PENDING|taskId={}|status={}|", taskId, task.getStatus());
return true;
}
int attempts = task.getAttemptCount() == null ? 0 : task.getAttemptCount();
int max = Math.max(1, properties.getCallbackMaxAttempts());
task.setStatus(STATUS_RUNNING);
task.setAttemptCount(attempts + 1);
task.setStartProcessTime(LocalDateTime.now());
task.setResultMsg("处理中(第" + (attempts + 1) + "次)");
task.setUpdateTime(LocalDateTime.now());
taskMapper.updateById(task);
long t0 = System.currentTimeMillis();
try {
if ("ORDER_STATUS".equalsIgnoreCase(task.getNotifyType())) {
Map<String, Object> body = StrUtil.isBlank(task.getReqJson()) ? Map.of()
: JSONUtil.parseObj(task.getReqJson());
Map<String, Object> result = orderService.handleOrderStatusNotify(body);
finish(task, STATUS_SUCCESS, "订单状态更新: " + JSONUtil.toJsonStr(result), t0);
return true;
}
if (task.getHotelId() == null && StrUtil.isNotBlank(task.getHotelCode())) {
SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, task.getHotelCode())
.last("limit 1"));
if (hotel != null) {
task.setHotelId(hotel.getId());
task.setHotelName(hotel.getHotelName());
taskMapper.updateById(task);
}
}
if (task.getHotelId() == null) {
finish(task, STATUS_SKIPPED, "本地无匹配酒店(hotelCode=" + task.getHotelCode() + "),请先导入/授权", t0);
return true;
}
// 回调只拉通知日期区间,避免每次全量 90 天把单线程队列堵死
R<Map<String, Object>> pull = pullForCallback(task);
if (pull != null && pull.getCode() == 0) {
Object data = pull.getData();
finish(task, STATUS_SUCCESS,
"拉取成功" + (data == null ? "" : ": " + StrUtil.maxLength(JSONUtil.toJsonStr(data), 800)), t0);
return true;
}
String msg = pull == null ? "拉取返回空" : StrUtil.blankToDefault(pull.getMsg(), "拉取失败");
handleFailOrRetry(task, msg, t0, max);
return false;
}
catch (Exception e) {
String msg = StrUtil.maxLength(e.getMessage(), 800);
if (StrUtil.containsIgnoreCase(msg, "繁忙") || StrUtil.containsIgnoreCase(msg, "稍后再试")
|| StrUtil.containsIgnoreCase(msg, "Token")) {
log.warn("|回调队列|上游暂不可用,稍后重试|taskId={}|msg={}|", taskId, msg);
}
else {
log.error("|回调队列|处理异常|taskId={}|", taskId, e);
}
handleFailOrRetry(task, msg, t0, max);
return false;
}
}
/**
* 房价/房量回调:按报文 startDate~endDate 拉区间;缺日期则只拉今天起 7 天(远小于全量 90 天)。
*/
private R<Map<String, Object>> pullForCallback(SmHotelCallbackTaskEntity task) {
LocalDate start = parseDate(task.getStartDate());
LocalDate endInclusive = parseDate(task.getEndDate());
if (start == null && endInclusive == null) {
start = LocalDate.now();
endInclusive = start.plusDays(6);
}
else if (start == null) {
start = endInclusive;
}
else if (endInclusive == null) {
endInclusive = start;
}
if (endInclusive.isBefore(start)) {
LocalDate tmp = start;
start = endInclusive;
endInclusive = tmp;
}
// 防异常超长区间(回调一般就几天)
if (endInclusive.isAfter(start.plusDays(31))) {
endInclusive = start.plusDays(31);
}
LocalDate endExclusive = endInclusive.plusDays(1);
log.info("|回调队列|按区间拉取|taskId={}|hotelId={}|{}~{}|", task.getId(), task.getHotelId(), start,
endInclusive);
return midService.pullPriceRange(task.getHotelId(), start, endExclusive);
}
private static LocalDate parseDate(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
String s = raw.trim();
try {
if (s.length() >= 10) {
return LocalDate.parse(s.substring(0, 10), DateTimeFormatter.ISO_LOCAL_DATE);
}
return LocalDate.parse(s, DateTimeFormatter.ISO_LOCAL_DATE);
}
catch (Exception e) {
return null;
}
}
private void handleFailOrRetry(SmHotelCallbackTaskEntity task, String msg, long t0, int max) {
int attempts = task.getAttemptCount() == null ? 1 : task.getAttemptCount();
if (attempts < max) {
task.setStatus(STATUS_PENDING);
task.setResultMsg("失败待重试(" + attempts + "/" + max + "): " + msg);
task.setCostMs(System.currentTimeMillis() - t0);
task.setUpdateTime(LocalDateTime.now());
taskMapper.updateById(task);
// 队列约定:入队 leftPush、消费 rightPop(FIFO);ORDER_STATUS 进高优队列仍 FIFO。
// 失败重试必须 leftPush 到队尾;rightPush 会插到 rightPop 端,等于立刻再跑同一条,饿死后面的新任务。
enqueueTaskId(task.getId(), task.getNotifyType());
return;
}
finish(task, STATUS_FAILED, msg, t0);
}
private void finish(SmHotelCallbackTaskEntity task, String status, String msg, long t0) {
task.setStatus(status);
task.setResultMsg(StrUtil.maxLength(msg, 1000));
task.setCostMs(System.currentTimeMillis() - t0);
task.setFinishTime(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
taskMapper.updateById(task);
log.info("|回调队列|完成|taskId={}|status={}|costMs={}|msg={}|", task.getId(), status, task.getCostMs(),
task.getResultMsg());
}
private SmHotelCallbackTaskEntity buildTask(String notifyType, Map<String, Object> body, String time,
Long tenantId) {
String hotelCode = str(body, "hotelId");
SmHotelEntity hotel = null;
if (StrUtil.isNotBlank(hotelCode)) {
hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, hotelCode)
.last("limit 1"));
}
SmHotelCallbackTaskEntity task = new SmHotelCallbackTaskEntity();
task.setTenantId(tenantId);
task.setNotifyType(StrUtil.blankToDefault(notifyType, "UNKNOWN"));
task.setHotelCode(hotelCode);
if (hotel != null) {
task.setHotelId(hotel.getId());
task.setHotelName(hotel.getHotelName());
}
task.setRoomTypeId(str(body, "roomTypeId"));
task.setStartDate(str(body, "startDate"));
task.setEndDate(str(body, "endDate"));
task.setModifyTime(str(body, "modifyTime"));
task.setFlagInfo(str(body, "flagInfo"));
task.setReqTime(time);
task.setReqJson(body == null ? null : JSONUtil.toJsonStr(body));
task.setStatus(STATUS_PENDING);
task.setAttemptCount(0);
task.setResultMsg(isHighPriority(notifyType) ? "已入队(高优先级),等待异步更新订单状态"
: "已入队,等待异步拉取");
task.setCreateBy("shangyou-callback");
task.setCreateTime(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
task.setDelFlag("0");
return task;
}
/** 订单状态回调最高优先:进高优队列,Worker 始终先于房价/房量消费 */
private void enqueueTaskId(Long taskId, String notifyType) {
String id = String.valueOf(taskId);
if (isHighPriority(notifyType)) {
redisTemplate.opsForList().leftPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH, id);
}
else {
redisTemplate.opsForList().leftPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, id);
}
}
private static boolean isHighPriority(String notifyType) {
return "ORDER_STATUS".equalsIgnoreCase(notifyType);
}
private static String str(Map<String, Object> body, String key) {
if (body == null || body.get(key) == null) {
return null;
}
String v = String.valueOf(body.get(key));
return "null".equals(v) ? null : v;
}
private static Map<String, Object> successResp() {
Map<String, Object> ok = new LinkedHashMap<>(4);
ok.put("code", "200");
ok.put("message", "成功!");
return ok;
}
private static Map<String, Object> failResp(String code, String message) {
Map<String, Object> ok = new LinkedHashMap<>(4);
ok.put("code", code);
ok.put("message", message);
return ok;
}
}
@@ -0,0 +1,961 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
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.supermarket.entity.*;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouBookStatus;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouOrderCallResult;
import com.bsd.pjcloud.supermarket.mapper.*;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 中转订单:可定校验 → 上游下单 → 不确定/3028/P 状态补偿(文档 3/5/10 分钟)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmHotelOrderEntity>
implements SmHotelOrderService {
/** 文档:下单后第 3 / 5 / 10 分钟查询 */
private static final int[] COMPENSATE_DELAY_MINUTES = { 3, 5, 10 };
private static final String COMP_NONE = "NONE";
private static final String COMP_WAITING = "WAITING";
private static final String COMP_DONE = "DONE";
private static final String COMP_NEED_MANUAL = "NEED_MANUAL";
private final SmHotelDownstreamAppMapper appMapper;
private final SmHotelMapper hotelMapper;
private final SmHotelProductMapper productMapper;
private final SmHotelDailyMapper dailyMapper;
private final ShangyouHotelClient upstreamClient;
private final SmHotelPriceConfigService priceConfigService;
private final com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties shangyouProperties;
@Override
public Page<SmHotelOrderEntity> pageOrders(Page<SmHotelOrderEntity> page, SmHotelOrderEntity query) {
return baseMapper.selectPage(page, Wrappers.<SmHotelOrderEntity>lambdaQuery()
.like(StrUtil.isNotBlank(query.getChannelNo()), SmHotelOrderEntity::getChannelNo, query.getChannelNo())
.like(StrUtil.isNotBlank(query.getPlatformNo()), SmHotelOrderEntity::getPlatformNo,
query.getPlatformNo())
.eq(StrUtil.isNotBlank(query.getOrderStatus()), SmHotelOrderEntity::getOrderStatus,
query.getOrderStatus())
.eq(StrUtil.isNotBlank(query.getBookStatus()), SmHotelOrderEntity::getBookStatus, query.getBookStatus())
.eq(StrUtil.isNotBlank(query.getCompensateStatus()), SmHotelOrderEntity::getCompensateStatus,
query.getCompensateStatus())
.eq(StrUtil.isNotBlank(query.getHotelCode()), SmHotelOrderEntity::getHotelCode, query.getHotelCode())
.orderByDesc(SmHotelOrderEntity::getCreateTime));
}
@Override
public SmHotelOrderEntity getDetail(Long id) {
return baseMapper.selectById(id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<JSONObject> createFromDownstream(String appKey, Map<String, Object> body) {
SmHotelDownstreamAppEntity app = requireApp(appKey);
String channelNo = str(body.get("channelNo"));
if (StrUtil.isBlank(channelNo)) {
return R.failed("channelNo 不能为空");
}
SmHotelOrderEntity exist = baseMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getChannelNo, channelNo)
.last("limit 1"));
if (exist != null) {
JSONObject data = buildOut(exist);
return R.ok(data, "订单已存在(幂等)");
}
String hotelCode = str(body.get("hotelCode"));
String groupCode = str(body.get("groupCode"));
String productCode = str(body.get("productCode"));
SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, hotelCode)
.eq(StrUtil.isNotBlank(groupCode), SmHotelEntity::getGroupCode, groupCode)
.last("limit 1"));
if (hotel == null) {
return R.failed("本地无此酒店,请先同步导入:" + hotelCode);
}
if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) {
return R.failed("酒店已停用,无法下单");
}
SmHotelProductEntity product = null;
if (StrUtil.isNotBlank(productCode)) {
product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotel.getId())
.eq(SmHotelProductEntity::getProductCode, productCode)
.last("limit 1"));
}
if (product == null && StrUtil.isBlank(str(body.get("roomTypeCode")))
&& StrUtil.isBlank(str(body.get("priceCode")))) {
return R.failed("产品不存在,且未传 roomTypeCode/priceCode");
}
SmHotelOrderEntity order = new SmHotelOrderEntity();
order.setDownstreamAppId(app.getId());
order.setHotelId(hotel.getId());
order.setChannelNo(channelNo);
order.setGroupCode(groupCode);
order.setHotelCode(hotelCode);
order.setRoomTypeCode(str(body.get("roomTypeCode")));
order.setProductCode(productCode);
order.setPriceCode(str(body.get("priceCode")));
order.setCheckInDate(parseDate(body.get("checkInDate")));
order.setCheckOutDate(parseDate(body.get("checkOutDate")));
order.setCount(body.get("count") == null ? 1 : Integer.parseInt(String.valueOf(body.get("count"))));
order.setContactName(str(body.get("contactName")));
order.setContactPhone(str(body.get("contactPhone")));
if (body.get("guestList") != null) {
order.setGuestJson(JSONUtil.toJsonStr(body.get("guestList")));
}
order.setMarkupPercent(priceConfigService.currentMarkupPercent());
order.setSellAmount(toDecimal(body.get("totalAmount")));
if (product != null && product.getCostPrice() != null && order.getCount() != null) {
order.setCostAmount(product.getCostPrice().multiply(BigDecimal.valueOf(order.getCount())));
}
order.setOrderStatus("CREATED");
order.setCompensateStatus(COMP_NONE);
order.setCompensateRound(0);
order.setReqJson(JSONUtil.toJsonStr(body));
if (product != null) {
if (StrUtil.isBlank(order.getRoomTypeCode())) {
order.setRoomTypeCode(product.getRoomTypeCode());
}
if (StrUtil.isBlank(order.getPriceCode())) {
order.setPriceCode(product.getPriceCode());
}
}
baseMapper.insert(order);
com.alibaba.fastjson.JSONObject upstreamBody = buildUpstreamOrderBody(order, product, body);
// 1) 下单前 DoubleCheck(文档 checkAvailability)
try {
String availMsg = doubleCheckAvailability(upstreamBody, order.getCount());
if (availMsg != null) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg(StrUtil.sub(availMsg, 0, 500));
baseMapper.updateById(order);
return R.failed(availMsg);
}
}
catch (Exception e) {
log.warn("|酒店下单|可定校验异常,继续下单|channelNo={}|msg={}|", channelNo, e.getMessage());
order.setLastCompensateMsg("可定校验异常已忽略: " + StrUtil.sub(e.getMessage(), 0, 200));
}
// 2) 上游下单
ShangyouOrderCallResult call = upstreamClient.createOrderRaw(upstreamBody);
applyCreateResult(order, call, upstreamBody);
baseMapper.updateById(order);
JSONObject out = buildOut(order);
if ("UPSTREAM_FAIL".equals(order.getOrderStatus()) && !COMP_WAITING.equals(order.getCompensateStatus())) {
return R.failed(StrUtil.blankToDefault(order.getFailMsg(), "上游下单失败"));
}
return R.ok(out);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<JSONObject> createByAdmin(Map<String, Object> body) {
if (body == null) {
return R.failed("请求体不能为空");
}
Long hotelId = null;
Object hotelIdObj = body.get("hotelId");
if (hotelIdObj != null && StrUtil.isNotBlank(String.valueOf(hotelIdObj))) {
hotelId = Long.valueOf(String.valueOf(hotelIdObj));
}
SmHotelEntity hotel = null;
if (hotelId != null) {
hotel = hotelMapper.selectById(hotelId);
}
if (hotel == null) {
String hotelCode = str(body.get("hotelCode"));
if (StrUtil.isNotBlank(hotelCode)) {
hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, hotelCode)
.last("limit 1"));
}
}
if (hotel == null) {
return R.failed("酒店不存在,请传 hotelId 或 hotelCode");
}
if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) {
return R.failed("酒店已停用,无法下单");
}
String productCode = str(body.get("productCode"));
if (StrUtil.isBlank(productCode)) {
return R.failed("productCode 不能为空");
}
SmHotelProductEntity product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotel.getId())
.eq(SmHotelProductEntity::getProductCode, productCode)
.last("limit 1"));
if (product == null) {
return R.failed("产品不存在,请先拉取酒店详情");
}
if (StrUtil.isBlank(product.getRoomTypeCode()) || StrUtil.isBlank(product.getPriceCode())) {
return R.failed("产品缺少房型码或房价码,请重新拉取上游详情");
}
LocalDate checkIn = parseDate(body.get("checkInDate"));
LocalDate checkOut = parseDate(body.get("checkOutDate"));
if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
return R.failed("入住/离店日期无效");
}
String contactName = str(body.get("contactName"));
String contactPhone = str(body.get("contactPhone"));
if (StrUtil.hasBlank(contactName, contactPhone)) {
return R.failed("联系人姓名和手机号不能为空");
}
String channelNo = str(body.get("channelNo"));
if (StrUtil.isBlank(channelNo)) {
channelNo = "ADMIN" + System.currentTimeMillis() + (int) (Math.random() * 900 + 100);
}
SmHotelOrderEntity exist = baseMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getChannelNo, channelNo)
.last("limit 1"));
if (exist != null) {
return R.ok(buildOut(exist), "订单已存在(幂等)");
}
int count = body.get("count") == null ? 1 : Integer.parseInt(String.valueOf(body.get("count")));
if (count < 1) {
count = 1;
}
SmHotelOrderEntity order = new SmHotelOrderEntity();
order.setDownstreamAppId(null);
order.setHotelId(hotel.getId());
order.setChannelNo(channelNo);
order.setGroupCode(hotel.getGroupCode());
order.setHotelCode(hotel.getHotelCode());
order.setRoomTypeCode(StrUtil.blankToDefault(str(body.get("roomTypeCode")), product.getRoomTypeCode()));
order.setProductCode(productCode);
order.setPriceCode(StrUtil.blankToDefault(str(body.get("priceCode")), product.getPriceCode()));
order.setCheckInDate(checkIn);
order.setCheckOutDate(checkOut);
order.setCount(count);
order.setContactName(contactName);
order.setContactPhone(contactPhone);
if (body.get("guestList") != null) {
order.setGuestJson(JSONUtil.toJsonStr(body.get("guestList")));
}
else {
Map<String, Object> guest = new java.util.HashMap<>(2);
guest.put("name", contactName);
guest.put("mobile", contactPhone);
order.setGuestJson(JSONUtil.toJsonStr(List.of(guest)));
}
order.setMarkupPercent(priceConfigService.currentMarkupPercent());
if (product.getSellPrice() != null) {
order.setSellAmount(product.getSellPrice().multiply(BigDecimal.valueOf(count)));
}
else if (body.get("totalAmount") != null) {
order.setSellAmount(toDecimal(body.get("totalAmount")));
}
if (product.getCostPrice() != null) {
order.setCostAmount(product.getCostPrice().multiply(BigDecimal.valueOf(count)));
}
order.setOrderStatus("CREATED");
order.setCompensateStatus(COMP_NONE);
order.setCompensateRound(0);
order.setReqJson(JSONUtil.toJsonStr(body));
baseMapper.insert(order);
com.alibaba.fastjson.JSONObject upstreamBody = buildUpstreamOrderBody(order, product, body);
try {
String availMsg = doubleCheckAvailability(upstreamBody, order.getCount());
if (availMsg != null) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg(StrUtil.sub(availMsg, 0, 500));
baseMapper.updateById(order);
return R.failed(availMsg);
}
}
catch (Exception e) {
log.warn("|后台试下单|可定校验异常,继续下单|channelNo={}|msg={}|", channelNo, e.getMessage());
order.setLastCompensateMsg("可定校验异常已忽略: " + StrUtil.sub(e.getMessage(), 0, 200));
}
ShangyouOrderCallResult call = upstreamClient.createOrderRaw(upstreamBody);
applyCreateResult(order, call, upstreamBody);
baseMapper.updateById(order);
JSONObject out = buildOut(order);
out.set("hotelName", hotel.getHotelName());
out.set("productName", product.getProductName());
if ("UPSTREAM_FAIL".equals(order.getOrderStatus()) && !COMP_WAITING.equals(order.getCompensateStatus())) {
return R.failed(StrUtil.blankToDefault(order.getFailMsg(), "上游下单失败"));
}
return R.ok(out, "下单已提交");
}
/**
* @return null=通过;非空=失败原因
*/
private String doubleCheckAvailability(com.alibaba.fastjson.JSONObject upstreamBody, Integer roomCount) {
com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject();
req.put("hotelId", upstreamBody.getString("hotelId"));
req.put("roomTypeId", upstreamBody.getString("roomTypeId"));
req.put("rateCode", upstreamBody.getString("rateCode"));
req.put("checkInDate", upstreamBody.getString("checkInDate"));
req.put("checkOutDate", upstreamBody.getString("checkOutDate"));
req.put("guaranteeMode", StrUtil.blankToDefault(upstreamBody.getString("guaranteeMode"), "NONE"));
req.put("isGroup", upstreamBody.getBooleanValue("isGroup"));
if (StrUtil.isNotBlank(upstreamBody.getString("activityId"))) {
req.put("activityId", upstreamBody.getString("activityId"));
}
com.alibaba.fastjson.JSONObject content = upstreamClient.checkAvailability(req);
com.alibaba.fastjson.JSONObject roomCountObj = content.getJSONObject("roomCount");
int need = roomCount == null ? 1 : roomCount;
if (roomCountObj != null) {
Integer available = roomCountObj.getInteger("availableCount");
if (available != null && available < need) {
return "可定校验失败:可用房量 " + available + " < 需求 " + need;
}
}
com.alibaba.fastjson.JSONArray daily = content.getJSONArray("roomCountDailyList");
if (daily != null) {
for (int i = 0; i < daily.size(); i++) {
com.alibaba.fastjson.JSONObject d = daily.getJSONObject(i);
if (Boolean.TRUE.equals(d.getBoolean("isForbidden")) || Boolean.TRUE.equals(d.getBoolean("isBlack"))) {
return "可定校验失败:日期 " + d.getString("bizDate") + " 禁售/黑名单";
}
Integer available = d.getInteger("availableCount");
if (available != null && available < need) {
return "可定校验失败:日期 " + d.getString("bizDate") + " 可用房量不足";
}
}
}
com.alibaba.fastjson.JSONArray priceDaily = content.getJSONArray("priceDailyList");
if (priceDaily != null) {
for (int i = 0; i < priceDaily.size(); i++) {
com.alibaba.fastjson.JSONObject d = priceDaily.getJSONObject(i);
if (Boolean.TRUE.equals(d.getBoolean("isForbidden")) || Boolean.TRUE.equals(d.getBoolean("isBlack"))) {
return "可定校验失败:房价日 " + d.getString("bizDate") + " 禁售/黑名单";
}
}
}
return null;
}
private void applyCreateResult(SmHotelOrderEntity order, ShangyouOrderCallResult call,
com.alibaba.fastjson.JSONObject upstreamBody) {
if (call.getRaw() != null) {
order.setUpstreamRespJson(call.getRaw().toJSONString());
}
else if (call.isUncertain()) {
order.setUpstreamRespJson("{\"uncertain\":true,\"message\":\""
+ StrUtil.blankToDefault(call.getMessage(), "").replace("\"", "'") + "\"}");
}
// 不确定结果(超时/5xx)或付款失败 3028 → 补偿
if (call.isUncertain() || call.isPayFail3028()) {
order.setOrderStatus("PENDING_CONFIRM");
order.setFailMsg(StrUtil.sub(
call.isPayFail3028() ? ("付款失败3028: " + call.getMessage()) : ("不确定结果: " + call.getMessage()), 0,
500));
if (StrUtil.isNotBlank(call.orderId())) {
order.setPlatformNo(call.orderId());
}
if (StrUtil.isNotBlank(call.bookStatus())) {
applyBookStatus(order, call.bookStatus(), call.payStatus());
}
scheduleCompensate(order, "下单后进入补偿队列");
return;
}
if (!call.isBizOk()) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg(StrUtil.sub("code=" + call.getCode() + " " + call.getMessage(), 0, 500));
order.setCompensateStatus(COMP_NONE);
return;
}
order.setPlatformNo(call.orderId());
order.setPayStatus(call.payStatus());
String book = call.bookStatus();
applyBookStatus(order, book, call.payStatus());
if (ShangyouBookStatus.isProcessing(book) || StrUtil.isBlank(book)) {
// P 或未返回状态:按文档继续查
if (StrUtil.isBlank(book)) {
order.setOrderStatus("PENDING_CONFIRM");
}
scheduleCompensate(order, "bookStatus=P 或空,进入状态轮询");
}
else if (ShangyouBookStatus.isTerminal(book)) {
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
order.setLastCompensateMsg("下单即时终态 " + book);
}
}
private void scheduleCompensate(SmHotelOrderEntity order, String reason) {
order.setCompensateStatus(COMP_WAITING);
order.setCompensateRound(0);
LocalDateTime base = order.getCreateTime() == null ? LocalDateTime.now() : order.getCreateTime();
order.setNextCompensateTime(base.plusMinutes(COMPENSATE_DELAY_MINUTES[0]));
order.setLastCompensateMsg(StrUtil.sub(reason + ";下次=" + order.getNextCompensateTime(), 0, 500));
log.info("|酒店订单补偿|入队|orderId={}|channelNo={}|next={}|reason={}|", order.getId(), order.getChannelNo(),
order.getNextCompensateTime(), reason);
}
@Override
public int processDueCompensate(int limit) {
LocalDateTime now = LocalDateTime.now();
List<SmHotelOrderEntity> due = baseMapper.selectList(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getCompensateStatus, COMP_WAITING)
.le(SmHotelOrderEntity::getNextCompensateTime, now)
.orderByAsc(SmHotelOrderEntity::getNextCompensateTime)
.last("limit " + Math.max(1, limit)));
int n = 0;
for (SmHotelOrderEntity row : due) {
Long tenantId = row.getTenantId() != null ? row.getTenantId() : TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = 1L;
}
try {
Long tid = tenantId;
TenantBroker.runAs(tid, id -> compensateOne(row.getId(), false));
n++;
}
catch (Exception e) {
log.error("|酒店订单补偿|异常|orderId={}|", row.getId(), e);
}
}
return n;
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> compensateNow(Long id) {
SmHotelOrderEntity order = baseMapper.selectById(id);
if (order == null) {
return R.failed("订单不存在");
}
compensateOne(id, true);
return R.ok(Boolean.TRUE, "已执行补偿查询");
}
private void compensateOne(Long orderId, boolean force) {
SmHotelOrderEntity order = baseMapper.selectById(orderId);
if (order == null) {
return;
}
if (!force) {
if (!COMP_WAITING.equals(order.getCompensateStatus())) {
log.info("|酒店订单补偿|跳过|orderId={}|status={}|comp={}|", orderId, order.getOrderStatus(),
order.getCompensateStatus());
return;
}
if (order.getNextCompensateTime() != null && order.getNextCompensateTime().isAfter(LocalDateTime.now())) {
return;
}
}
else {
order.setCompensateStatus(COMP_WAITING);
}
int round = order.getCompensateRound() == null ? 0 : order.getCompensateRound();
int nextRound = round + 1;
log.info("|酒店订单补偿|开始|orderId={}|channelNo={}|round={}|", orderId, order.getChannelNo(), nextRound);
ShangyouOrderCallResult query = upstreamClient.queryByRequestNoRaw(order.getChannelNo());
if (query.isUncertain() || (!query.isBizOk() && StrUtil.isBlank(query.orderId()))) {
// 查不到确定结果:文档允许用原 requestNo 重试 order/add
if (nextRound <= 2) {
ShangyouOrderCallResult retryAdd = retryOrderAdd(order);
if (retryAdd != null && (retryAdd.isBizOk() || StrUtil.isNotBlank(retryAdd.orderId()))) {
applyCompensateSuccess(order, retryAdd, nextRound, "重试 order/add");
return;
}
}
bumpOrManual(order, nextRound, "查询不确定: " + StrUtil.blankToDefault(query.getMessage(), query.getCode()));
return;
}
if (StrUtil.isBlank(query.orderId()) && StrUtil.isBlank(query.bookStatus())) {
// 尚无订单:尝试重试下单
ShangyouOrderCallResult retryAdd = retryOrderAdd(order);
if (retryAdd != null && (retryAdd.isBizOk() || StrUtil.isNotBlank(retryAdd.orderId()))) {
applyCompensateSuccess(order, retryAdd, nextRound, "查询无单后重试 order/add");
return;
}
bumpOrManual(order, nextRound, "queryByRequestNo 无订单");
return;
}
applyCompensateSuccess(order, query, nextRound, "queryByRequestNo");
}
private ShangyouOrderCallResult retryOrderAdd(SmHotelOrderEntity order) {
try {
Map<String, Object> body = StrUtil.isBlank(order.getReqJson()) ? Map.of()
: JSONUtil.parseObj(order.getReqJson());
SmHotelProductEntity product = null;
if (order.getHotelId() != null && StrUtil.isNotBlank(order.getProductCode())) {
product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, order.getHotelId())
.eq(SmHotelProductEntity::getProductCode, order.getProductCode())
.last("limit 1"));
}
com.alibaba.fastjson.JSONObject upstreamBody = buildUpstreamOrderBody(order, product, body);
return upstreamClient.createOrderRaw(upstreamBody);
}
catch (Exception e) {
log.warn("|酒店订单补偿|重试下单异常|channelNo={}|{}", order.getChannelNo(), e.getMessage());
return ShangyouOrderCallResult.uncertain(e.getMessage(), null);
}
}
private void applyCompensateSuccess(SmHotelOrderEntity order, ShangyouOrderCallResult call, int round,
String source) {
if (call.getRaw() != null) {
order.setUpstreamRespJson(call.getRaw().toJSONString());
}
if (StrUtil.isNotBlank(call.orderId())) {
order.setPlatformNo(call.orderId());
}
String book = call.bookStatus();
applyBookStatus(order, book, call.payStatus());
order.setCompensateRound(round);
order.setFailMsg(null);
if (ShangyouBookStatus.isProcessing(book) || StrUtil.isBlank(book)) {
if (round >= COMPENSATE_DELAY_MINUTES.length) {
order.setCompensateStatus(COMP_NEED_MANUAL);
order.setOrderStatus("NEED_MANUAL");
order.setNextCompensateTime(null);
order.setLastCompensateMsg(StrUtil.sub(
source + " 满 " + round + " 轮仍为 P,请对接群人工确认,避免差异单", 0, 500));
log.warn("|酒店订单补偿|需人工|channelNo={}|orderId={}|", order.getChannelNo(), order.getPlatformNo());
}
else {
order.setCompensateStatus(COMP_WAITING);
LocalDateTime base = order.getCreateTime() == null ? LocalDateTime.now() : order.getCreateTime();
order.setNextCompensateTime(base.plusMinutes(COMPENSATE_DELAY_MINUTES[round]));
order.setLastCompensateMsg(StrUtil.sub(source + " bookStatus=P,下次=" + order.getNextCompensateTime(), 0,
500));
}
}
else {
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
order.setLastCompensateMsg(StrUtil.sub(source + " 终态 bookStatus=" + book, 0, 500));
}
baseMapper.updateById(order);
}
private void bumpOrManual(SmHotelOrderEntity order, int round, String msg) {
order.setCompensateRound(round);
if (round >= COMPENSATE_DELAY_MINUTES.length) {
order.setCompensateStatus(COMP_NEED_MANUAL);
order.setOrderStatus("NEED_MANUAL");
order.setNextCompensateTime(null);
order.setLastCompensateMsg(StrUtil.sub(msg + ";已满轮次,需人工确认", 0, 500));
}
else {
order.setCompensateStatus(COMP_WAITING);
LocalDateTime base = order.getCreateTime() == null ? LocalDateTime.now() : order.getCreateTime();
order.setNextCompensateTime(base.plusMinutes(COMPENSATE_DELAY_MINUTES[round]));
order.setLastCompensateMsg(StrUtil.sub(msg + ";下次=" + order.getNextCompensateTime(), 0, 500));
}
baseMapper.updateById(order);
}
private void applyBookStatus(SmHotelOrderEntity order, String bookStatus, String payStatus) {
if (StrUtil.isNotBlank(bookStatus)) {
order.setBookStatus(bookStatus.trim().toUpperCase());
String local = ShangyouBookStatus.toLocalOrderStatus(order.getBookStatus());
if (StrUtil.isNotBlank(local)) {
order.setOrderStatus(local);
}
}
if (StrUtil.isNotBlank(payStatus)) {
order.setPayStatus(payStatus);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> handleOrderStatusNotify(Map<String, Object> body) {
// 由 UpstreamController 验签后调用;此处只更新订单,供异步队列或同步轻量调用
String orderId = str(body.get("orderId"));
String outerRefId = str(body.get("outerRefId"));
String bookStatus = str(body.get("bookStatus"));
SmHotelOrderEntity order = null;
if (StrUtil.isNotBlank(orderId)) {
order = baseMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getPlatformNo, orderId)
.last("limit 1"));
}
if (order == null && StrUtil.isNotBlank(outerRefId)) {
order = baseMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getChannelNo, outerRefId)
.last("limit 1"));
}
if (order == null) {
log.warn("|订单状态回调|未找到本地单|orderId={}|outerRefId={}|", orderId, outerRefId);
return Map.of("updated", false, "reason", "order_not_found");
}
applyBookStatus(order, bookStatus, str(body.get("payStatus")));
if (ShangyouBookStatus.isTerminal(bookStatus)) {
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
}
order.setUpstreamRespJson(JSONUtil.toJsonStr(body));
order.setLastCompensateMsg(StrUtil.sub("状态回调更新 bookStatus=" + bookStatus, 0, 500));
baseMapper.updateById(order);
return Map.of("updated", true, "localOrderId", order.getId(), "orderStatus", order.getOrderStatus());
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<JSONObject> cancelFromDownstream(String appKey, Map<String, Object> body) {
requireApp(appKey);
String channelNo = str(body.get("channelNo"));
SmHotelOrderEntity order = baseMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getChannelNo, channelNo)
.last("limit 1"));
if (order == null) {
return R.failed("订单不存在");
}
if (StrUtil.isBlank(order.getPlatformNo())) {
return R.failed("尚无上游订单号,无法取消;若在补偿中请等待或人工确认");
}
try {
boolean ok = upstreamClient.cancelOrder(order.getPlatformNo(), order.getHotelCode());
if (!ok) {
return R.failed("上游取消返回失败");
}
applyBookStatus(order, ShangyouBookStatus.X, order.getPayStatus());
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
baseMapper.updateById(order);
return R.ok(buildOut(order));
}
catch (Exception e) {
return R.failed("上游取消失败: " + e.getMessage());
}
}
@Override
public R<JSONObject> queryFromDownstream(String appKey, Map<String, Object> body) {
requireApp(appKey);
String channelNo = str(body.get("channelNo"));
SmHotelOrderEntity order = baseMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getChannelNo, channelNo)
.last("limit 1"));
if (order == null) {
return R.failed("订单不存在");
}
// 有平台号则刷新一次上游状态
try {
if (StrUtil.isNotBlank(order.getPlatformNo())) {
com.alibaba.fastjson.JSONObject content = upstreamClient.queryOrder(order.getPlatformNo(),
order.getHotelCode());
applyBookStatus(order, content.getString("bookStatus"), content.getString("payStatus"));
order.setUpstreamRespJson(content.toJSONString());
baseMapper.updateById(order);
}
else if (COMP_WAITING.equals(order.getCompensateStatus())
|| "PENDING_CONFIRM".equals(order.getOrderStatus())) {
compensateOne(order.getId(), true);
order = baseMapper.selectById(order.getId());
}
}
catch (Exception e) {
log.warn("|下游查单|刷新上游失败|channelNo={}|{}", channelNo, e.getMessage());
}
JSONObject out = buildOut(order);
out.set("groupCode", order.getGroupCode());
out.set("hotelCode", order.getHotelCode());
out.set("checkInDate", order.getCheckInDate());
out.set("checkOutDate", order.getCheckOutDate());
out.set("sellAmount", order.getSellAmount());
out.set("costAmount", order.getCostAmount());
out.set("bookStatus", order.getBookStatus());
out.set("payStatus", order.getPayStatus());
out.set("compensateStatus", order.getCompensateStatus());
return R.ok(out);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> cancelByAdmin(Long id, String reason) {
SmHotelOrderEntity order = baseMapper.selectById(id);
if (order == null) {
return R.failed("订单不存在");
}
if (StrUtil.isBlank(order.getPlatformNo())) {
return R.failed("尚无上游订单号");
}
try {
boolean ok = upstreamClient.cancelOrder(order.getPlatformNo(), order.getHotelCode());
if (!ok) {
return R.failed("上游取消返回失败");
}
applyBookStatus(order, ShangyouBookStatus.X, order.getPayStatus());
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
if (StrUtil.isNotBlank(reason)) {
order.setFailMsg(StrUtil.sub(reason, 0, 200));
}
baseMapper.updateById(order);
return R.ok(true);
}
catch (Exception e) {
return R.failed("取消失败: " + e.getMessage());
}
}
private JSONObject buildOut(SmHotelOrderEntity order) {
JSONObject out = new JSONObject();
out.set("channelNo", order.getChannelNo());
out.set("platformNo", order.getPlatformNo());
out.set("orderStatus", order.getOrderStatus());
out.set("bookStatus", order.getBookStatus());
out.set("payStatus", order.getPayStatus());
out.set("compensateStatus", order.getCompensateStatus());
out.set("innerOrderId", order.getId());
out.set("failMsg", order.getFailMsg());
out.set("lastCompensateMsg", order.getLastCompensateMsg());
return out;
}
/**
* 组装玉编电云 order/add 请求体。
* <p>
* 字段名按官方文档:linkFirstName/linkMobile、guestInfoList、bookerPersonFirstName 等;
* dailyPriceList 必须覆盖入住夜(checkOut-checkIn),缺夜用产品底价补齐。
*/
private com.alibaba.fastjson.JSONObject buildUpstreamOrderBody(SmHotelOrderEntity order,
SmHotelProductEntity product, Map<String, Object> downstreamBody) {
com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject();
req.put("requestNo", order.getChannelNo());
req.put("outerRefId", order.getChannelNo());
req.put("hotelId", order.getHotelCode());
req.put("roomTypeId", order.getRoomTypeCode());
req.put("rateCode", order.getPriceCode());
req.put("checkInType", "DAY");
req.put("checkInDate", order.getCheckInDate() == null ? null : order.getCheckInDate().toString());
req.put("checkOutDate", order.getCheckOutDate() == null ? null : order.getCheckOutDate().toString());
req.put("roomCount", order.getCount() == null ? 1 : order.getCount());
// 文档必填:联系人 linkLastName + linkFirstName(中文须拆姓/名,不能整名塞 FirstName)
NameParts link = splitPersonName(order.getContactName());
req.put("linkLastName", link.lastName());
req.put("linkFirstName", link.firstName());
req.put("linkMobile", order.getContactPhone());
boolean isGroup = resolveOrderIsGroup(downstreamBody);
String guarantee = StrUtil.blankToDefault(str(downstreamBody.get("guaranteeMode")),
StrUtil.blankToDefault(shangyouProperties.getOrderGuaranteeMode(), "NONE"));
req.put("guaranteeMode", guarantee);
req.put("isGroup", isGroup);
req.put("apiType", "INTERFACE");
req.put("travelType", isGroup ? "COMPANY" : "PERSON");
if (isGroup) {
req.put("bookerPriceType", "COMPANY");
}
req.put("isNeedRegisterMember", false);
// 文档:bookerPersonId 必填;无员工号时用手机号兜底
req.put("bookerPersonId",
StrUtil.blankToDefault(str(downstreamBody.get("bookerPersonId")), order.getContactPhone()));
NameParts booker = splitPersonName(order.getContactName());
req.put("bookerPersonLastName", booker.lastName());
req.put("bookerPersonFirstName", booker.firstName());
req.put("bookerPersonMobile", order.getContactPhone());
req.put("adults", downstreamBody.get("adults") == null ? 2
: Integer.parseInt(String.valueOf(downstreamBody.get("adults"))));
String guestRawName = order.getContactName();
String guestMobile = order.getContactPhone();
String guestLastOverride = null;
String guestFirstOverride = null;
if (StrUtil.isNotBlank(order.getGuestJson())) {
try {
JSONArray guests = JSONUtil.parseArray(order.getGuestJson());
if (guests != null && !guests.isEmpty()) {
JSONObject g0 = guests.getJSONObject(0);
guestLastOverride = g0.getStr("guestLastName");
guestFirstOverride = g0.getStr("guestFirstName");
guestRawName = StrUtil.blankToDefault(g0.getStr("name"),
StrUtil.blankToDefault(guestFirstOverride, guestRawName));
if (StrUtil.isNotBlank(guestLastOverride) && StrUtil.isNotBlank(guestFirstOverride)) {
guestRawName = guestLastOverride + guestFirstOverride;
}
guestMobile = StrUtil.blankToDefault(
StrUtil.blankToDefault(g0.getStr("guestPhone"), g0.getStr("mobile")), guestMobile);
}
}
catch (Exception ignored) {
}
}
NameParts guest = (StrUtil.isNotBlank(guestLastOverride) && StrUtil.isNotBlank(guestFirstOverride))
? new NameParts(guestLastOverride, guestFirstOverride)
: splitPersonName(guestRawName);
com.alibaba.fastjson.JSONArray guestInfoList = new com.alibaba.fastjson.JSONArray();
com.alibaba.fastjson.JSONObject guestRow = new com.alibaba.fastjson.JSONObject();
guestRow.put("guestLastName", guest.lastName());
guestRow.put("guestFirstName", guest.firstName());
guestRow.put("guestPhone", guestMobile);
guestRow.put("guestRoomNo", 1);
guestInfoList.add(guestRow);
req.put("guestInfoList", guestInfoList);
Object remark = downstreamBody.get("remark");
if (remark != null) {
req.put("remark", String.valueOf(remark));
}
Object arriveTime = downstreamBody.get("arriveTime");
if (arriveTime != null) {
req.put("arriveTime", String.valueOf(arriveTime));
}
req.put("dailyPriceList", buildDailyPriceList(order, product));
return req;
}
private boolean resolveOrderIsGroup(Map<String, Object> body) {
Object v = body == null ? null : body.get("isGroup");
if (v instanceof Boolean) {
return (Boolean) v;
}
if (v != null && StrUtil.isNotBlank(String.valueOf(v))) {
return Boolean.parseBoolean(String.valueOf(v));
}
return shangyouProperties.isOrderIsGroup();
}
/**
* 上游姓名格式:LastName=姓 / FirstName=名。
* <ul>
* <li>中文:首字为姓,其余为名(复姓未特殊处理;单字名时名为 "/")</li>
* <li>英文空格分隔:按 First Last 或 Last First 取两段</li>
* <li>其它:整段进 LastName,FirstName="/"</li>
* </ul>
*/
private static NameParts splitPersonName(String raw) {
String name = StrUtil.trim(raw);
if (StrUtil.isBlank(name)) {
return new NameParts("测", "试");
}
// 去掉中间空格后的纯中文
String compact = name.replace(" ", "").replace(" ", "");
if (compact.chars().allMatch(ch -> Character.UnicodeScript.of(ch) == Character.UnicodeScript.HAN)) {
if (compact.length() == 1) {
return new NameParts(compact, "/");
}
return new NameParts(compact.substring(0, 1), compact.substring(1));
}
// 英文:含空格则拆成两段(名 姓 → first last;若多段取首为名、末为姓)
String[] parts = name.trim().split("\\s+");
if (parts.length >= 2) {
return new NameParts(parts[parts.length - 1], parts[0]);
}
return new NameParts(name, "/");
}
private record NameParts(String lastName, String firstName) {
}
/**
* 按住店夜补齐 dailyPriceList;本地日历缺夜则用产品底价兜底。
*/
private List<com.alibaba.fastjson.JSONObject> buildDailyPriceList(SmHotelOrderEntity order,
SmHotelProductEntity product) {
Map<LocalDate, BigDecimal> byDate = new java.util.LinkedHashMap<>();
if (order.getHotelId() != null && StrUtil.isNotBlank(order.getProductCode())
&& order.getCheckInDate() != null && order.getCheckOutDate() != null) {
List<SmHotelDailyEntity> dailies = dailyMapper
.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getHotelId, order.getHotelId())
.eq(SmHotelDailyEntity::getProductCode, order.getProductCode())
.ge(SmHotelDailyEntity::getStayDate, order.getCheckInDate())
.lt(SmHotelDailyEntity::getStayDate, order.getCheckOutDate())
.orderByAsc(SmHotelDailyEntity::getStayDate));
for (SmHotelDailyEntity d : dailies) {
byDate.put(d.getStayDate(), d.getCostPrice() == null ? BigDecimal.ZERO : d.getCostPrice());
}
}
BigDecimal fallback = product != null && product.getCostPrice() != null ? product.getCostPrice()
: BigDecimal.ZERO;
List<com.alibaba.fastjson.JSONObject> dailyPriceList = new ArrayList<>();
if (order.getCheckInDate() != null && order.getCheckOutDate() != null) {
for (LocalDate d = order.getCheckInDate(); d.isBefore(order.getCheckOutDate()); d = d.plusDays(1)) {
com.alibaba.fastjson.JSONObject row = new com.alibaba.fastjson.JSONObject();
row.put("bizDate", d.toString());
row.put("afterTaxPrice", byDate.getOrDefault(d, fallback));
dailyPriceList.add(row);
}
}
return dailyPriceList;
}
private SmHotelDownstreamAppEntity requireApp(String appKey) {
if (StrUtil.isBlank(appKey)) {
throw new IllegalArgumentException("缺少 appKey");
}
SmHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getAppKey, appKey)
.eq(SmHotelDownstreamAppEntity::getStatus, "1")
.last("limit 1"));
if (app == null) {
throw new IllegalArgumentException("无效的下游 appKey");
}
return app;
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
private static LocalDate parseDate(Object o) {
if (o == null) {
return null;
}
return LocalDate.parse(String.valueOf(o).substring(0, 10));
}
private static BigDecimal toDecimal(Object o) {
if (o == null) {
return null;
}
return new BigDecimal(String.valueOf(o));
}
}
@@ -0,0 +1,116 @@
package com.bsd.pjcloud.supermarket.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelPriceConfigMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper;
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;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedHashMap;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
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 final SmHotelProductMapper productMapper;
private final SmHotelDailyMapper dailyMapper;
@Override
public R<SmHotelPriceConfigEntity> getCurrent() {
SmHotelPriceConfigEntity one = baseMapper.selectOne(Wrappers.<SmHotelPriceConfigEntity>lambdaQuery()
.orderByDesc(SmHotelPriceConfigEntity::getUpdateTime)
.last("limit 1"));
if (one == null) {
one = new SmHotelPriceConfigEntity();
one.setMarkupPercent(BigDecimal.ZERO);
}
return R.ok(one);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> saveOrUpdateConfig(SmHotelPriceConfigEntity entity) {
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%");
}
SmHotelPriceConfigEntity exist = baseMapper.selectOne(Wrappers.<SmHotelPriceConfigEntity>lambdaQuery()
.last("limit 1"));
if (exist == null) {
return R.ok(save(entity));
}
exist.setMarkupPercent(p);
exist.setRemark(entity.getRemark());
return R.ok(updateById(exist));
}
@Override
public BigDecimal currentMarkupPercent() {
SmHotelPriceConfigEntity one = baseMapper.selectOne(Wrappers.<SmHotelPriceConfigEntity>lambdaQuery()
.last("limit 1"));
return one == null || one.getMarkupPercent() == null ? BigDecimal.ZERO : one.getMarkupPercent();
}
@Override
public BigDecimal applyMarkup(BigDecimal costPrice) {
return applyMarkup(costPrice, currentMarkupPercent());
}
@Override
public BigDecimal applyMarkup(BigDecimal costPrice, BigDecimal percent) {
if (costPrice == null) {
return null;
}
BigDecimal p = percent == null ? BigDecimal.ZERO : percent;
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);
}
return costPrice.multiply(rate).setScale(2, RoundingMode.HALF_UP);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Map<String, Object>> resyncAllSellPrices() {
BigDecimal percent = currentMarkupPercent();
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);
Map<String, Object> out = new LinkedHashMap<>();
out.put("markupPercent", percent);
out.put("rate", rate);
out.put("productUpdated", productUpdated);
out.put("dailyUpdated", dailyUpdated);
return R.ok(out);
}
}
@@ -0,0 +1,543 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelReader;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.alibaba.excel.read.metadata.ReadSheet;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelRoomMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelService;
import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity>
implements SmHotelService {
/** 多值 INSERT 一批条数(与人脸明细导入同量级) */
private static final int BATCH = 1500;
private static final int SKIP_SAMPLE_LIMIT = 50;
private static final ConcurrentHashMap<String, SmHotelImportResultVO> IMPORT_TASKS = new ConcurrentHashMap<>();
private final SmHotelRoomMapper roomMapper;
private final SmHotelProductMapper productMapper;
private final SmHotelDailyMapper dailyMapper;
@Override
public Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page,
LambdaQueryWrapper<SmHotelEntity> wrapper) {
Page<SmHotelEntity> result = baseMapper.selectPageByScope(page, wrapper, DataScope.of());
fillDetailCounts(result.getRecords());
return result;
}
/** 为本页酒店批量补齐房型/产品/日历数量,避免 N+1 */
private void fillDetailCounts(List<SmHotelEntity> records) {
if (CollUtil.isEmpty(records)) {
return;
}
List<Long> ids = records.stream().map(SmHotelEntity::getId).filter(id -> id != null).toList();
if (ids.isEmpty()) {
return;
}
Map<Long, Integer> roomMap = toCountMap(roomMapper.countGroupByHotelIds(ids));
Map<Long, Integer> productMap = toCountMap(productMapper.countGroupByHotelIds(ids));
Map<Long, Integer> dailyMap = toCountMap(dailyMapper.countGroupByHotelIds(ids));
for (SmHotelEntity h : records) {
Long id = h.getId();
h.setRoomCount(roomMap.getOrDefault(id, 0));
h.setProductCount(productMap.getOrDefault(id, 0));
h.setDailyCount(dailyMap.getOrDefault(id, 0));
}
}
private static Map<Long, Integer> toCountMap(List<Map<String, Object>> rows) {
Map<Long, Integer> map = new HashMap<>();
if (CollUtil.isEmpty(rows)) {
return map;
}
for (Map<String, Object> row : rows) {
Object hid = row.get("hotelId");
if (hid == null) {
hid = row.get("hotel_id");
}
Object cnt = row.get("cnt");
if (hid == null || cnt == null) {
continue;
}
map.put(Long.valueOf(String.valueOf(hid)), Integer.valueOf(String.valueOf(cnt)));
}
return map;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean saveHotel(SmHotelEntity entity) {
normalize(entity);
if (StrUtil.isBlank(entity.getHotelName()) && StrUtil.isBlank(entity.getHotelCode())) {
throw new IllegalArgumentException("酒店名称与酒店编码不能同时为空");
}
if (StrUtil.isNotBlank(entity.getHotelCode()) && existsHotelCode(entity.getHotelCode(), null)) {
throw new IllegalArgumentException("酒店编码已存在:" + entity.getHotelCode());
}
if (entity.getDeptId() == null) {
entity.setDeptId(SecurityUtils.getUser().getDeptId());
}
return baseMapper.insert(entity) > 0;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateHotel(SmHotelEntity entity) {
if (entity.getId() == null) {
throw new IllegalArgumentException("酒店ID不能为空");
}
SmHotelEntity old = baseMapper.selectById(entity.getId());
if (old == null) {
throw new IllegalArgumentException("酒店不存在");
}
normalize(entity);
if (StrUtil.isBlank(entity.getHotelName()) && StrUtil.isBlank(entity.getHotelCode())) {
throw new IllegalArgumentException("酒店名称与酒店编码不能同时为空");
}
if (StrUtil.isNotBlank(entity.getHotelCode()) && existsHotelCode(entity.getHotelCode(), entity.getId())) {
throw new IllegalArgumentException("酒店编码已存在:" + entity.getHotelCode());
}
return baseMapper.updateById(entity) > 0;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean removeHotels(Long[] ids) {
if (ids == null || ids.length == 0) {
return Boolean.TRUE;
}
return removeByIds(CollUtil.toList(ids));
}
@Override
public R<SmHotelImportResultVO> startImport(MultipartFile file) {
if (file == null || file.isEmpty()) {
return R.failed("请上传酒店 Excel");
}
String taskId = IdUtil.getSnowflakeNextIdStr();
SmHotelImportResultVO vo = new SmHotelImportResultVO();
vo.setTaskId(taskId);
vo.setStatus("RUNNING");
vo.setMessage("已提交,后台导入中…");
IMPORT_TASKS.put(taskId, vo);
File temp;
try {
temp = Files.createTempFile("sm-hotel-", ".xlsx").toFile();
file.transferTo(temp);
}
catch (Exception e) {
vo.setStatus("FAILED");
vo.setMessage("接收上传文件失败:" + e.getMessage());
return R.failed(vo.getMessage());
}
Long tenantId = TenantContextHolder.getTenantId();
PjcloudUser user = SecurityUtils.getUser();
Long deptId = user != null ? user.getDeptId() : null;
String operator = user != null ? user.getUsername() : "system";
final File tempFile = temp;
Thread worker = new Thread(() -> {
TenantContextHolder.setTenantId(tenantId);
try {
doImportAsync(tempFile, vo, deptId, tenantId, operator);
}
catch (Throwable e) {
log.error("酒店Excel异步导入失败 taskId={}", taskId, e);
vo.setStatus("FAILED");
vo.setMessage(StrUtil.maxLength(StrUtil.blankToDefault(e.getMessage(), e.getClass().getSimpleName()), 500));
}
finally {
TenantContextHolder.clear();
if (tempFile.exists()) {
FileUtil.del(tempFile);
}
}
}, "sm-hotel-import-" + taskId);
worker.setDaemon(false);
worker.start();
return R.ok(vo, "已提交导入,请稍候查看进度");
}
@Override
public R<SmHotelImportResultVO> getImportStatus(String taskId) {
if (StrUtil.isBlank(taskId)) {
return R.failed("taskId 不能为空");
}
SmHotelImportResultVO vo = IMPORT_TASKS.get(taskId);
if (vo == null) {
return R.failed("导入任务不存在或已过期,请重新导入");
}
return R.ok(vo);
}
/**
* 一次打开 Excel 读完全部 Sheet;库内先预加载编码索引,再按批 multi-values INSERT / 批量 UPDATE。
*/
private void doImportAsync(File tempFile, SmHotelImportResultVO result, Long deptId, Long tenantId,
String operator) throws Exception {
long t0 = System.currentTimeMillis();
List<ReadSheet> sheetMetas = listSheetMetas(tempFile);
if (CollUtil.isEmpty(sheetMetas)) {
result.setStatus("FAILED");
result.setMessage("Excel 中没有任何 Sheet");
return;
}
result.setSheetCount(sheetMetas.size());
result.setMessage("预加载已有酒店索引…");
Map<String, Long> codeIdMap = new HashMap<>();
Map<String, Long> sourceIdMap = new HashMap<>();
preloadIndex(codeIdMap, sourceIdMap);
List<SmHotelEntity> buffer = new ArrayList<>(BATCH);
ImportFlushContext ctx = new ImportFlushContext(result, buffer, codeIdMap, sourceIdMap, deptId, tenantId,
operator);
result.setMessage("解析 Excel(共 " + sheetMetas.size() + " 个 Sheet)…");
try (FileInputStream in = new FileInputStream(tempFile)) {
ExcelReader reader = EasyExcel.read(in).build();
try {
List<ReadSheet> readSheets = new ArrayList<>(sheetMetas.size());
int index = 0;
for (ReadSheet meta : sheetMetas) {
index++;
int sheetNo = meta.getSheetNo() == null ? 0 : meta.getSheetNo();
String sheetName = StrUtil.blankToDefault(meta.getSheetName(), "Sheet" + (sheetNo + 1));
result.getSheetNames().add(sheetName);
HotelSheetListener listener = new HotelSheetListener(sheetName, index, ctx);
readSheets.add(EasyExcel.readSheet(sheetNo)
.headRowNumber(1)
.registerReadListener(listener)
.build());
}
reader.read(readSheets);
}
finally {
reader.finish();
}
}
flushBuffer(ctx);
long cost = System.currentTimeMillis() - t0;
result.setStatus("SUCCESS");
result.setMessage(String.format("导入完成:Sheet %d 个,读取 %d 行,新增 %d,更新 %d,跳过 %d,耗时 %d ms",
result.getSheetCount(), result.getReadCount(), result.getInsertCount(), result.getUpdateCount(),
result.getSkipCount(), cost));
log.info("酒店导入完成 taskId={} insert={} update={} skip={} costMs={}", result.getTaskId(),
result.getInsertCount(), result.getUpdateCount(), result.getSkipCount(), cost);
}
private void preloadIndex(Map<String, Long> codeIdMap, Map<String, Long> sourceIdMap) {
List<SmHotelEntity> existing = baseMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
.select(SmHotelEntity::getId, SmHotelEntity::getHotelCode, SmHotelEntity::getSourceId));
for (SmHotelEntity e : existing) {
if (StrUtil.isNotBlank(e.getHotelCode())) {
codeIdMap.putIfAbsent(e.getHotelCode(), e.getId());
}
if (StrUtil.isNotBlank(e.getSourceId())) {
sourceIdMap.putIfAbsent(e.getSourceId(), e.getId());
}
}
}
private static List<ReadSheet> listSheetMetas(File tempFile) throws Exception {
try (FileInputStream metaIn = new FileInputStream(tempFile)) {
ExcelReader metaReader = EasyExcel.read(metaIn).build();
try {
return metaReader.excelExecutor().sheetList();
}
finally {
metaReader.finish();
}
}
}
private void flushBuffer(ImportFlushContext ctx) {
List<SmHotelEntity> buffer = ctx.buffer;
if (buffer.isEmpty()) {
return;
}
LocalDateTime now = LocalDateTime.now();
List<SmHotelEntity> toInsert = new ArrayList<>();
List<SmHotelEntity> toUpdate = new ArrayList<>();
for (SmHotelEntity entity : buffer) {
Long existsId = null;
if (StrUtil.isNotBlank(entity.getHotelCode())) {
existsId = ctx.codeIdMap.get(entity.getHotelCode());
}
if (existsId == null && StrUtil.isNotBlank(entity.getSourceId())) {
existsId = ctx.sourceIdMap.get(entity.getSourceId());
}
if (existsId != null) {
entity.setId(existsId);
entity.setUpdateBy(ctx.operator);
entity.setUpdateTime(now);
toUpdate.add(entity);
ctx.result.setUpdateCount(ctx.result.getUpdateCount() + 1);
}
else {
entity.setId(IdWorker.getId());
entity.setTenantId(ctx.tenantId);
entity.setDeptId(ctx.deptId);
entity.setCreateBy(ctx.operator);
entity.setUpdateBy(ctx.operator);
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setDelFlag(CommonConstants.STATUS_NORMAL);
toInsert.add(entity);
ctx.result.setInsertCount(ctx.result.getInsertCount() + 1);
if (StrUtil.isNotBlank(entity.getHotelCode())) {
ctx.codeIdMap.put(entity.getHotelCode(), entity.getId());
}
if (StrUtil.isNotBlank(entity.getSourceId())) {
ctx.sourceIdMap.put(entity.getSourceId(), entity.getId());
}
}
}
if (CollUtil.isNotEmpty(toInsert)) {
// 多值 INSERT,避免 saveBatch 逐条/小批
for (int i = 0; i < toInsert.size(); i += BATCH) {
List<SmHotelEntity> slice = toInsert.subList(i, Math.min(i + BATCH, toInsert.size()));
baseMapper.insertBatch(slice);
}
}
if (CollUtil.isNotEmpty(toUpdate)) {
updateBatchById(toUpdate, BATCH);
for (SmHotelEntity entity : toUpdate) {
if (StrUtil.isNotBlank(entity.getHotelCode())) {
ctx.codeIdMap.put(entity.getHotelCode(), entity.getId());
}
if (StrUtil.isNotBlank(entity.getSourceId())) {
ctx.sourceIdMap.put(entity.getSourceId(), entity.getId());
}
}
}
buffer.clear();
}
private boolean existsHotelCode(String hotelCode, Long excludeId) {
LambdaQueryWrapper<SmHotelEntity> w = Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, hotelCode);
if (excludeId != null) {
w.ne(SmHotelEntity::getId, excludeId);
}
return baseMapper.selectCount(w) > 0;
}
private static void normalize(SmHotelEntity entity) {
entity.setSourceId(trimToNull(entity.getSourceId()));
entity.setGroupCode(trimToNull(entity.getGroupCode()));
entity.setHotelCode(trimToNull(entity.getHotelCode()));
entity.setSGroupId(trimToNull(entity.getSGroupId()));
entity.setSHotelId(trimToNull(entity.getSHotelId()));
entity.setHotelName(trimToNull(entity.getHotelName()));
entity.setSheetName(trimToNull(entity.getSheetName()));
entity.setRemark(trimToNull(entity.getRemark()));
}
private static String trimToNull(String v) {
return StrUtil.isBlank(v) ? null : v.trim();
}
private static void addSkipSample(SmHotelImportResultVO result, String sheet, int excelRow, String reason) {
if (result.getSkipSamples().size() >= SKIP_SAMPLE_LIMIT) {
return;
}
result.getSkipSamples().add(sheet + " 第" + excelRow + "行:" + reason);
}
private final class ImportFlushContext {
final SmHotelImportResultVO result;
final List<SmHotelEntity> buffer;
final Map<String, Long> codeIdMap;
final Map<String, Long> sourceIdMap;
final Long deptId;
final Long tenantId;
final String operator;
ImportFlushContext(SmHotelImportResultVO result, List<SmHotelEntity> buffer,
Map<String, Long> codeIdMap, Map<String, Long> sourceIdMap, Long deptId, Long tenantId,
String operator) {
this.result = result;
this.buffer = buffer;
this.codeIdMap = codeIdMap;
this.sourceIdMap = sourceIdMap;
this.deptId = deptId;
this.tenantId = tenantId;
this.operator = operator;
}
}
private final class HotelSheetListener extends AnalysisEventListener<Map<Integer, String>> {
private final String sheetName;
private final int sheetIndex;
private final ImportFlushContext ctx;
private final Map<Integer, String> fieldByCol = new HashMap<>();
private String headError;
HotelSheetListener(String sheetName, int sheetIndex, ImportFlushContext ctx) {
this.sheetName = sheetName;
this.sheetIndex = sheetIndex;
this.ctx = ctx;
}
@Override
public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
ctx.result.setSheetIndex(sheetIndex);
fieldByCol.clear();
if (headMap == null || headMap.isEmpty()) {
headError = "表头为空";
return;
}
for (Map.Entry<Integer, String> e : headMap.entrySet()) {
String field = resolveField(e.getValue());
if (field != null) {
fieldByCol.put(e.getKey(), field);
}
}
if (!fieldByCol.containsValue("hotelName") && !fieldByCol.containsValue("hotelCode")
&& !fieldByCol.containsValue("sourceId")) {
headError = "未识别到 id / hotel_code / hotel_name 等必要表头";
}
if (StrUtil.isNotBlank(headError)) {
ctx.result.setSkipCount(ctx.result.getSkipCount() + 1);
addSkipSample(ctx.result, sheetName, 1, headError);
}
else {
ctx.result.setMessage("正在导入 Sheet「" + sheetName + "」(" + sheetIndex + "/"
+ ctx.result.getSheetCount() + ")…");
}
}
@Override
public void invoke(Map<Integer, String> data, AnalysisContext context) {
if (StrUtil.isNotBlank(headError) || data == null) {
return;
}
int excelRow = context.readRowHolder().getRowIndex() + 1;
ctx.result.setReadCount(ctx.result.getReadCount() + 1);
String sourceId = null;
String groupCode = null;
String hotelCode = null;
String sGroupId = null;
String sHotelId = null;
String hotelName = null;
for (Map.Entry<Integer, String> e : data.entrySet()) {
String field = fieldByCol.get(e.getKey());
if (field == null) {
continue;
}
String val = StrUtil.trim(e.getValue());
switch (field) {
case "sourceId" -> sourceId = val;
case "groupCode" -> groupCode = val;
case "hotelCode" -> hotelCode = val;
case "sGroupId" -> sGroupId = val;
case "sHotelId" -> sHotelId = val;
case "hotelName" -> hotelName = val;
default -> {
}
}
}
boolean blank = StrUtil.isBlank(sourceId) && StrUtil.isBlank(groupCode) && StrUtil.isBlank(hotelCode)
&& StrUtil.isBlank(sGroupId) && StrUtil.isBlank(sHotelId) && StrUtil.isBlank(hotelName);
if (blank) {
ctx.result.setSkipCount(ctx.result.getSkipCount() + 1);
return;
}
if (StrUtil.isBlank(hotelName) && StrUtil.isBlank(hotelCode) && StrUtil.isBlank(sourceId)) {
ctx.result.setSkipCount(ctx.result.getSkipCount() + 1);
addSkipSample(ctx.result, sheetName, excelRow, "缺少酒店名称/编码/外部id");
return;
}
SmHotelEntity entity = new SmHotelEntity();
entity.setSourceId(trimToNull(sourceId));
entity.setGroupCode(trimToNull(groupCode));
entity.setHotelCode(trimToNull(hotelCode));
entity.setSGroupId(trimToNull(sGroupId));
entity.setSHotelId(trimToNull(sHotelId));
entity.setHotelName(trimToNull(hotelName));
entity.setSheetName(sheetName);
ctx.buffer.add(entity);
if (ctx.buffer.size() >= BATCH) {
flushBuffer(ctx);
ctx.result.setMessage(String.format("Sheet「%s」已落库约 %d 行(新增 %d / 更新 %d)…", sheetName,
ctx.result.getReadCount(), ctx.result.getInsertCount(), ctx.result.getUpdateCount()));
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 外层统一 flush
}
private static String resolveField(String header) {
if (StrUtil.isBlank(header)) {
return null;
}
String h = header.trim().toLowerCase(Locale.ROOT).replace(" ", "").replace("_", "");
Map<String, String> alias = new LinkedHashMap<>();
alias.put("id", "sourceId");
alias.put("sourceid", "sourceId");
alias.put("外部id", "sourceId");
alias.put("groupcode", "groupCode");
alias.put("集团编码", "groupCode");
alias.put("hotelcode", "hotelCode");
alias.put("酒店编码", "hotelCode");
alias.put("sgroupid", "sGroupId");
alias.put("外部集团id", "sGroupId");
alias.put("shotelid", "sHotelId");
alias.put("外部酒店id", "sHotelId");
alias.put("hotelname", "hotelName");
alias.put("酒店名称", "hotelName");
alias.put("酒店名", "hotelName");
return alias.get(h);
}
}
}
@@ -0,0 +1,214 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson.JSONArray;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelCallbackTaskService;
import com.bsd.pjcloud.supermarket.service.SmHotelUpstreamService;
import com.bsd.pjcloud.common.core.util.R;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 玉编电云上游对接:资源查询 + 本地回写 + 变更通知。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelUpstreamServiceImpl implements SmHotelUpstreamService {
private final ShangyouHotelClient client;
private final SmHotelMapper hotelMapper;
private final SmHotelCallbackTaskService callbackTaskService;
@Override
@Transactional(rollbackFor = Exception.class)
public R<JSONObject> syncHotelInfo(Long hotelId) {
if (hotelId == null) {
return R.failed("酒店ID不能为空");
}
SmHotelEntity hotel = hotelMapper.selectById(hotelId);
if (hotel == null) {
return R.failed("酒店不存在");
}
if (StrUtil.isBlank(hotel.getHotelCode())) {
return R.failed("本地酒店缺少 hotelCode(上游 hotelId),无法同步");
}
JSONArray list = client.getHotelComplexList(Collections.singletonList(hotel.getHotelCode()));
com.alibaba.fastjson.JSONObject complex = list.isEmpty() ? new com.alibaba.fastjson.JSONObject()
: list.getJSONObject(0);
applyHotelComplex(hotel, complex);
hotelMapper.updateById(hotel);
return R.ok(toHutool(complex));
}
@Override
public R<Map<String, Object>> syncHotelInfoBatch(List<Long> hotelIds) {
if (CollUtil.isEmpty(hotelIds)) {
return R.failed("请选择酒店");
}
int ok = 0;
List<String> errors = new ArrayList<>();
for (Long id : hotelIds) {
try {
R<JSONObject> r = syncHotelInfo(id);
if (r != null && r.getCode() == 0) {
ok++;
}
else {
errors.add(id + ":" + (r == null ? "失败" : r.getMsg()));
}
}
catch (Exception e) {
errors.add(id + ":" + e.getMessage());
}
}
Map<String, Object> result = new HashMap<>(4);
result.put("successCount", ok);
result.put("failCount", hotelIds.size() - ok);
result.put("errors", errors);
return R.ok(result);
}
@Override
public R<JSONObject> hotelInfo(String groupCode, String hotelCode) {
JSONArray list = client.getHotelComplexList(Collections.singletonList(hotelCode));
return R.ok(toHutool(list.isEmpty() ? new com.alibaba.fastjson.JSONObject() : list.getJSONObject(0)));
}
@Override
public R<JSONObject> roomTypeList(String groupCode, String hotelCode) {
JSONArray list = client.getHotelComplexList(Collections.singletonList(hotelCode));
com.alibaba.fastjson.JSONObject complex = list.isEmpty() ? new com.alibaba.fastjson.JSONObject()
: list.getJSONObject(0);
JSONObject out = new JSONObject();
out.set("hotelId", complex.getString("hotelId"));
out.set("hotelRoomTypeList", complex.getJSONArray("hotelRoomTypeList"));
return R.ok(out);
}
@Override
public R<JSONObject> productList(String groupCode, String hotelCode, String channelCode) {
// 产品来自房价接口,默认查今天+1 天样例
String checkIn = java.time.LocalDate.now().toString();
String checkOut = java.time.LocalDate.now().plusDays(1).toString();
com.alibaba.fastjson.JSONObject content = client.getRoomPrice(hotelCode, checkIn, checkOut, null);
return R.ok(toHutool(content));
}
@Override
public R<JSONObject> orderCreate(Map<String, Object> body) {
com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject(body);
return R.ok(toHutool(client.createOrder(req)));
}
@Override
public R<JSONObject> orderCancel(Map<String, Object> body) {
String orderId = first(body, "orderId", "platFormNo", "platformNo");
String hotelId = first(body, "hotelId", "hotelCode");
boolean ok = client.cancelOrder(orderId, hotelId);
JSONObject out = new JSONObject();
out.set("content", ok);
return R.ok(out);
}
@Override
public R<JSONObject> orderGetById(Map<String, Object> body) {
String orderId = first(body, "orderId", "platFormNo", "platformNo");
String hotelId = first(body, "hotelId", "hotelCode");
return R.ok(toHutool(client.queryOrder(orderId, hotelId)));
}
@Override
public R<JSONObject> orderCheck(Map<String, Object> body) {
com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject(body);
return R.ok(toHutool(client.checkAvailability(req)));
}
@Override
public R<JSONObject> handleNotify(Map<String, Object> body) {
return handleNotify(body, null, null);
}
@Override
public R<JSONObject> handleNotify(Map<String, Object> body, String time, String sign) {
Map<String, Object> raw = handleShangyouNotify("LEGACY", body, time, sign);
JSONObject resp = JSONUtil.parseObj(JSONUtil.toJsonStr(raw));
if (!"200".equals(String.valueOf(raw.get("code")))) {
return R.failed(String.valueOf(raw.get("message")));
}
return R.ok(resp);
}
/**
* 房价/房量变更回调:验签后落库入队,立即返回;异步 Worker 再 pullFromUpstream。
*/
@Override
public Map<String, Object> handleShangyouNotify(String notifyType, Map<String, Object> body, String time,
String sign) {
return callbackTaskService.enqueueFromNotify(notifyType, body, time, sign);
}
private void applyHotelComplex(SmHotelEntity hotel, com.alibaba.fastjson.JSONObject data) {
if (data == null || data.isEmpty()) {
return;
}
if (StrUtil.isNotBlank(data.getString("hotelName"))) {
hotel.setHotelName(data.getString("hotelName"));
}
if (StrUtil.isNotBlank(data.getString("hotelId"))) {
hotel.setHotelCode(data.getString("hotelId"));
}
if (StrUtil.isNotBlank(data.getString("brandId")) && StrUtil.isBlank(hotel.getGroupCode())) {
hotel.setGroupCode(data.getString("brandId"));
}
String address = data.getString("hotelAddress");
String city = data.getString("cityName");
String tel = data.getString("hotelTelephone");
if (StrUtil.isNotBlank(address) || StrUtil.isNotBlank(city) || StrUtil.isNotBlank(tel)) {
hotel.setRemark(StrUtil.sub(
StrUtil.format("city={};addr={};tel={}", StrUtil.nullToEmpty(city), StrUtil.nullToEmpty(address),
StrUtil.nullToEmpty(tel)),
0, 250));
}
}
private static JSONObject toHutool(com.alibaba.fastjson.JSONObject obj) {
if (obj == null) {
return new JSONObject();
}
return JSONUtil.parseObj(obj.toJSONString());
}
private static String first(Map<String, Object> body, String... keys) {
if (body == null || keys == null) {
return null;
}
for (String k : keys) {
Object v = body.get(k);
if (v != null && StrUtil.isNotBlank(String.valueOf(v))) {
return String.valueOf(v);
}
}
return null;
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}
@@ -23,3 +23,105 @@ spring:
# username: root
# password: root
# url: jdbc:mysql://pjcloud-mysql:3306/pjcloudx_supermarket?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8&allowMultiQueries=true
## ========== 玉编电云 / TMC CRS 酒店上游(docs/shangyou)==========
## 生产对方通常只给一个根地址:https://onlink.yubdianyun.cn/link/provider
## 此时只配 base-url 即可:鉴权 POST {base-url}/oauth/token ,业务 POST {base-url}/hotel/...
## 测试若鉴权与业务拆成两个地址,再单独配 auth-base-url / biz-base-url
#shangyou-hotel:
# # 是否启用上游调用(false 时拉酒店/房价/下单都会直接报「未启用」)
# enabled: true
# # 上游根地址(生产常用这一项即可,同时用于鉴权+业务)
# base-url: https://onlink.yubdianyun.cn/link/provider
# # 鉴权根地址;一般留空,自动用 base-url。仅当测试环境鉴权与业务分离时才填,例 https://xxx/link
# # auth-base-url: ""
# # 业务根地址;一般留空,自动用 base-url。仅当业务地址与 base-url 不同时才填
# # biz-base-url: ""
# # OAuth clientId(对方开通账号时下发,用于换 access_token)
# client-id: F210AE02B74C48A3901B
# # OAuth clientSecret(与 client-id 配对,Basic 鉴权换 token)
# client-secret: 99165116A43F413CB799C91714861408
# # 业务卡号 cardId(账号),请求体 content 里会带
# card-id: 9082727358de43be96fe
# # 业务秘钥 cardPassWord,请求体 content 里会带;也可兼作回调验签 Secret
# card-password: 5bffd3c42e5a42219872bace3092983d
# # 房价/房量回调验签 Secret:sign=MD5(Secret+time);留空则用上面的 card-password
# callback-secret: 99165116A43F413CB799C91714861408
# # 下单预订人 bookerId;留空则用 card-id;以对方开通为准
# booker-id: ""
# # 是否总对总下单(企业中转一般 true;现付调试可 false)
# order-is-group: true
# # 担保模式:NONE / FIRST_NIGHT / FULL_AMOUNT,以对方开卡为准
# order-guarantee-mode: NONE
# # 房价接口路径。生产必须用 /hotel/getRoomPrice(/hotel/getTMCRoomPrice 会 404)
# room-price-path: /hotel/getRoomPrice
# # 会员等级,逗号分隔;对方要求传时再填,空则不传该字段
# member-levels: ""
# # 调上游 HTTP 超时(毫秒),含拉酒店列表/房价等
# timeout-ms: 60000
# # token 提前作废秒数(对方 token 默认约 21600 秒);避免边界时刻用到已失效 token
# token-skew-seconds: 120
# # 「拉取上游」时拉未来多少天房价;建议 1~90;接口单次约 31 天,代码会自动按段切片
# price-days: 90
# # 授权酒店列表 Redis 缓存分钟数(HASH+ZSET,不落 JVM 全量,支持十几万级)
# hotel-list-cache-minutes: 30
# # 授权酒店数量告警阈值(仅打日志告警,不截断);十几万级强制刷新建议低峰做
# hotel-list-warn-size: 100000
# # 回调无 TENANT-ID:写入任务/匹配本地酒店用的默认租户(酒店中转一般单租户)
# callback-tenant-id: 1
# # 是否启用回调异步队列 Worker(Redis BRPOP 消费后 pullFromUpstream)
# callback-queue-enabled: true
# # 单条回调任务最大尝试次数(含首次;失败会自动重入队)
# callback-max-attempts: 3
# # 回调队列处理完一家后的间隔毫秒(房价另有 QPS 限流,这里可较小)
# callback-task-interval-ms: 50
# # 房价 getRoomPrice 限流(对方约定约 20 QPS)。多实例请均分,如 2 实例各配 10;≤0 关闭
# price-api-qps: 20
# # ----- 日更 / 差集 -----
# # 酒店列表日同步开关(false 则定时/XXL 都跳过)
# hotel-list-daily-sync-enabled: true
# # 是否启用本服务 Spring Cron(改下面 cron 即可生效;已用 XXL 请设 false,避免一天跑两次)
# spring-schedule-enabled: true
# # 列表日更时间:默认每天 02:00(Spring Cron 6 段:秒 分 时 日 月 周)
# hotel-list-daily-cron: "0 0 2 * * ?"
# # 滚价日更时间:默认每天 03:00
# price-roll-daily-cron: "0 0 3 * * ?"
# # 列表同步后是否自动把「上游有本地无」写入本地主数据(会按批循环导完)
# auto-import-new-hotels: true
# # 自动导入每批条数(控内存;同步会循环多批直到差集清空,不是总共只导这么多)
# auto-import-batch-size: 2000
# # 每日滚第 N 天房价开关
# price-roll-daily-enabled: true
# # 滚价偏移:today + N(文档第 91 天 → 90)
# price-roll-day-offset: 90
# # 滚价按本地酒店 id 游标每批数量(防 OOM)
# price-roll-batch-size: 100
# # 全量房价房量:每天最多次数 / 最小间隔小时(「拉取全部启用详情」受此限制)
# full-price-sync-max-per-day: 3
# full-price-sync-min-interval-hours: 6
# # 全量拉详情:每家间隔毫秒(落库已批量,默认 0;上游压力大时可调大)
# detail-pull-interval-ms: 0
# # 全量拉详情单次最多家数;0=不限制(酒店很多时建议先设 100 试跑)
# detail-pull-max-hotels: 0
# # 差集扫描 / 重建本地 code SET 批大小
# diff-scan-batch-size: 500
# # 同步后:本地有上游无 → 自动标 OFFLINE 停用
# auto-mark-offline-hotels: true
# # 同步后:已停用但上游又出现 → 自动恢复 ACTIVE
# auto-reactivate-online-hotels: true
#
## ========== 下游销售方推送(《第三方数据同步接口文档》)==========
## 本系统作为中转 A,主动把加价后的酒店/房价推到下游 B(与上面「上游」无关,可单独开关)
#hotel-downstream:
# # 是否启用向下游推送
# enabled: false
# # 下游 B 系统根地址(不含尾斜杠),实际请求 = base-url + /third/party/api/...
# base-url: https://b.example.com
# # B 系统分配给我们的 appKey(请求头/签名用)
# app-key: TP_xxx
# # B 系统分配的 appSecret(仅本地算签名,不落报文)
# app-secret: your-secret
# # 推下游 HTTP 超时(毫秒)
# timeout-ms: 30000
# # 兼容字段;实际上游拉取天数以 shangyou-hotel.price-days 为准
# daily-days: 90
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper">
<insert id="insertBatch">
INSERT INTO canteen_hotel_daily (
id, tenant_id, hotel_id, product_id, room_type_code, product_code, stay_date,
cost_price, sell_price, stock, daily_status,
create_by, create_time, update_by, update_time, del_flag
) VALUES
<foreach collection="list" item="e" separator=",">
(
#{e.id}, #{e.tenantId}, #{e.hotelId}, #{e.productId}, #{e.roomTypeCode}, #{e.productCode},
#{e.stayDate}, #{e.costPrice}, #{e.sellPrice}, #{e.stock}, #{e.dailyStatus},
#{e.createBy}, #{e.createTime}, #{e.updateBy}, #{e.updateTime}, #{e.delFlag}
)
</foreach>
</insert>
</mapper>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.supermarket.mapper.SmHotelMapper">
<insert id="insertBatch">
INSERT INTO canteen_hotel (
id, tenant_id, dept_id, source_id, group_code, hotel_code, s_group_id, s_hotel_id,
hotel_name, sheet_name, remark, create_by, create_time, update_by, update_time, del_flag
) VALUES
<foreach collection="list" item="e" separator=",">
(
#{e.id}, #{e.tenantId}, #{e.deptId}, #{e.sourceId}, #{e.groupCode}, #{e.hotelCode},
#{e.sGroupId}, #{e.sHotelId}, #{e.hotelName}, #{e.sheetName}, #{e.remark},
#{e.createBy}, #{e.createTime}, #{e.updateBy}, #{e.updateTime}, #{e.delFlag}
)
</foreach>
</insert>
</mapper>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper">
<insert id="insertBatch">
INSERT INTO canteen_hotel_product (
id, tenant_id, hotel_id, room_id, group_code, hotel_code, room_type_code,
product_code, product_name, price_code, cancel_code, meal_type, meal_count,
cost_price, sell_price, stock, apartment_type, product_type, can_cancel, cancel_rule, raw_json,
create_by, create_time, update_by, update_time, del_flag
) VALUES
<foreach collection="list" item="e" separator=",">
(
#{e.id}, #{e.tenantId}, #{e.hotelId}, #{e.roomId}, #{e.groupCode}, #{e.hotelCode},
#{e.roomTypeCode}, #{e.productCode}, #{e.productName}, #{e.priceCode}, #{e.cancelCode},
#{e.mealType}, #{e.mealCount}, #{e.costPrice}, #{e.sellPrice}, #{e.stock},
#{e.apartmentType}, #{e.productType}, #{e.canCancel}, #{e.cancelRule}, #{e.rawJson},
#{e.createBy}, #{e.createTime}, #{e.updateBy}, #{e.updateTime}, #{e.delFlag}
)
</foreach>
</insert>
</mapper>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.supermarket.mapper.SmHotelRoomMapper">
<insert id="insertBatch">
INSERT INTO canteen_hotel_room (
id, tenant_id, hotel_id, group_code, hotel_code, room_type_code, room_type_name,
bed_type, capacity, room_number, raw_json,
create_by, create_time, update_by, update_time, del_flag
) VALUES
<foreach collection="list" item="e" separator=",">
(
#{e.id}, #{e.tenantId}, #{e.hotelId}, #{e.groupCode}, #{e.hotelCode},
#{e.roomTypeCode}, #{e.roomTypeName}, #{e.bedType}, #{e.capacity}, #{e.roomNumber},
#{e.rawJson}, #{e.createBy}, #{e.createTime}, #{e.updateBy}, #{e.updateTime}, #{e.delFlag}
)
</foreach>
</insert>
</mapper>
@@ -1,62 +1,26 @@
/*
*
* Copyright (c) 2018-2025, pjcloud All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* Neither the name of the pig4cloud.com developer nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
* Author: pjcloud
*
*/
package com.bsd.pjcloud.thirdparty.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.thirdparty.entity.ThirdPartyUserEntity;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
* @author pjcloud
* @date 2018/6/22
* 第三方用户相关 Feign(浙政钉/诗画单点已移除)。
*/
@FeignClient(contextId = "remoterThirdPartyAuthService", value = ServiceNameConstants.THIRDPARTY_SERVICE)
public interface RemoterThirdPartyAuthService {
/**
* zzd单点
* @param token
* @return
*/
@NoToken
@GetMapping("/thirdPartyOAth2/zzd/sso")
R zzdsso(@RequestParam("token") String token);
@NoToken
@GetMapping("/thirdPartyOAth2/zzd/shjnsso")
R shjnsso(@RequestParam("accountId") String accountId,@RequestParam("sessionId") String sessionId);
/**
* 根据钉钉号查询第三方表里面是否有这个人,也就是浙政钉总体系人员表
* @param employeeCode
* @return
* 按员工编码查询第三方用户表
*/
@NoToken
@GetMapping("/thirdPartyUser/getUserByEmployeeCode")
R<List<ThirdPartyUserEntity>> getUserByEmployeeCode(@RequestParam("employeeCode") String employeeCode);
}
@@ -1,21 +0,0 @@
package com.bsd.pjcloud.thirdparty.constant;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 出库入口
*/
@AllArgsConstructor
@Getter
public enum UserTypeEnum {
ZZD("1", "浙政钉用户登录失败"),
SH("2", "诗画用户登录失败"),
;
private final String type;
private final String description;
}
@@ -1,14 +0,0 @@
package com.bsd.pjcloud.thirdparty.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class SHJNSSODTO {
@NotNull(message = "账号id不能为空")
private String accountId;
@NotNull(message = "会员id不能为空")
private String sessionId;
}
@@ -1,9 +0,0 @@
package com.bsd.pjcloud.thirdparty.dto;
import lombok.Data;
@Data
public class UrlTokenDTO {
private String token;
}
@@ -1,11 +0,0 @@
package com.bsd.pjcloud.thirdparty.dto;
import lombok.Data;
@Data
public class ZZDResponse {
private String code;
private String description;
private String data;
}
@@ -1,17 +0,0 @@
package com.bsd.pjcloud.thirdparty.dto;
import lombok.Data;
@Data
public class ZZDUser {
private String username;
private int organizationId;
private int userId;
private String nickName;
private String mobile;
private String regionCode;
private String ext;
private String employeeCode;
}
@@ -1,28 +0,0 @@
package com.bsd.pjcloud.thirdparty.dto;
import lombok.Data;
@Data
public class ZZDUserDTO {
private String username;
private int organizationId;
private int userId;
private String nickName;
private String mobile;
private String regionCode;
private Ext ext;
@Data
public class Ext {
private String employeeCode;
private String organizationCode;
private String nickNameCn;
private String openid;
private String realmId;
private String mobile;
//以下字段,诗画独有
private String headImgUrl;
}
}
@@ -10,7 +10,7 @@ import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 浙政钉用户表
* 第三方用户表
*
* @author zjf
* @date 2024-12-03 13:38:08
@@ -19,7 +19,7 @@ import java.time.LocalDateTime;
@TenantTable
@TableName("third_party_user")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "浙政钉用户表")
@Schema(description = "第三方用户表")
public class ThirdPartyUserEntity extends Model<ThirdPartyUserEntity> {
@@ -68,9 +68,9 @@ public class ThirdPartyUserEntity extends Model<ThirdPartyUserEntity> {
private String nickName;
/**
* 员工浙政钉编码
* 员工钉钉编码
*/
@Schema(description="员工浙政钉编码")
@Schema(description="员工钉钉编码")
private String dingtalkEmployeeCode;
/**
@@ -23,15 +23,4 @@ public class OAuth2ClientConfig {
private String userDetailUrl;
/**
* 浙政钉获取token的接口
*/
private String zzdTokenUrl;
/**
* 浙政钉跟诗画的获取用户信息的url(诗画已直连,保留兼容)
*/
private String zzdShUserInfoUrl;
}
@@ -1,11 +1,8 @@
package com.bsd.pjcloud.thirdparty.controller;
import com.bsd.pjcloud.admin.api.dto.UserTokenDTO;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.entity.UserTrustedDeviceEntity;
import com.bsd.pjcloud.admin.api.feign.RemoteTokenService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserTrustedDeviceService;
import com.bsd.pjcloud.common.core.constant.CacheConstants;
import com.bsd.pjcloud.common.core.util.DeviceFingerprintUtil;
@@ -14,336 +11,112 @@ import com.bsd.pjcloud.common.core.util.WebUtils;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.thirdparty.dto.DeviceVerifyDTO;
import com.bsd.pjcloud.thirdparty.dto.SHJNSSODTO;
import com.bsd.pjcloud.thirdparty.dto.UrlTokenDTO;
import com.bsd.pjcloud.thirdparty.dto.ZZDUserDTO;
import com.bsd.pjcloud.thirdparty.service.SmsService;
import com.bsd.pjcloud.thirdparty.service.ThirdPartyOAuth2Service;
import com.bsd.pjcloud.thirdparty.service.ThirdPartyUserService;
import com.bsd.pjcloud.thirdparty.utils.SmsCodeUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.seata.spring.annotation.GlobalTransactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.*;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@RestController
@RequiredArgsConstructor
@RequestMapping("/thirdPartyOAth2" )
@Tag(description = "thirdPartyOAth2" , name = "第三方单点接口" )
@RequestMapping("/thirdPartyOAth2")
@Tag(description = "thirdPartyOAth2", name = "第三方登录辅助接口")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class ThirdPartyOAth2Cotroller {
private final ThirdPartyOAuth2Service thirdPartyOAuth2Service;
private final RemoteTokenService remoteTokenService;
private final RemoteUserService remoteUserService;
private final SmsService smsService;
private final RemoteTokenService remoteTokenService;
private final RemoteUserTrustedDeviceService remoteUserTrustedDeviceService;
private final ThirdPartyUserService thirdPartyUserService;
@Autowired
private RedisTemplate redisTemplate;
private final SmsService smsService;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
private final RemoteUserTrustedDeviceService remoteUserTrustedDeviceService;
// @Operation(summary = "浙政钉token" )
// @GetMapping("/zzd/sso" )
// public R getThirdPartyzzdsso(@RequestParam("token")String token) {
// return R.ok(thirdPartyOAuth2Service.getZZDUserInfo(token));
//
// }
@Operation(summary = "诗画token" )
@GetMapping("/sh/sso" )
public R getThirdPartyshsso(@RequestParam("accountId") String accountId,@RequestParam("sessionId") String sessionId) {
return R.ok(thirdPartyOAuth2Service.shjnsso(accountId,sessionId));
}
/*
*
* @param token 浙政钉前端获取的token
* @return
*/
@PostMapping("/zzdsso")
@Operation(summary = "浙政钉单点登录")
@SysLog("浙政钉单点登录")
@Inner(value = false)
@GlobalTransactional
@Transactional(rollbackFor = Exception.class)
public R zzdsso(@RequestBody UrlTokenDTO urlTokenDTO) {
String token = urlTokenDTO.getToken();
if (StringUtils.isBlank(token)){
return R.failed("token不能为空");
}
//获取数字政务token通过浙政钉前端获取的token,调试阶段先写死
// String json = "{" +
// "\"username\": \"李宗相\"," +
// "\"organizationId\": 2," +
// "\"userId\": 1340," +
// "\"nickName\": \"李宗相\"," +
// "\"mobile\": \"18576496089\"," +
// "\"regionCode\": null," +
// "\"ext\": {" +
// "\"employeeCode\": \"GE_ae12bdf21598498eb48bf2cf2c45a3e1\"," +
// "\"organizationCode\": \"GO_63e77077d5e041789bbbe1d0d20bcf41\"," +
// "\"nickNameCn\": \"李宗相\"," +
// "\"openid\": \"1b51f0e11fc73b8adddcbaf7982daab6\"," +
// "\"realmId\": \"196729\"," +
// "\"mobile\": null" +
// "}" +
// "}";
//
//
// ObjectMapper objectMapper = new ObjectMapper();
// ZZDUserDTO zzdUserDTO;
// try {
// zzdUserDTO = objectMapper.readValue(json, ZZDUserDTO.class);
// } catch (JsonProcessingException e) {
// throw new RuntimeException(e);
// }
//暂时先注释,模拟一下就行
ZZDUserDTO zzdUserDTO = thirdPartyOAuth2Service.zzdsso(token);
ZZDUserDTO.Ext ext = zzdUserDTO.getExt();
String employeeCode = ext.getEmployeeCode();
String mobile = zzdUserDTO.getMobile();
//两种方式,手机号+员工钉钉号,查到相同就视为同一个人,然后登录
//先系统表里面查看是否有,在第三方数据库表里面看下是否有
R<Map> r;
//1.根据手机号或者钉钉号先查询是否有这个人
R<List<SysUser>> userByMobileOrEmployeeCode = remoteUserService.getUserByMobileOrEmployeeCode(employeeCode, mobile);
List<SysUser> sysUserList = userByMobileOrEmployeeCode.getData();
if(!CollectionUtils.isEmpty(sysUserList)){
//登录成功生成token
UserTokenDTO userTokenDTO = new UserTokenDTO();
userTokenDTO.setSysUser(sysUserList.get(0));
r = remoteTokenService.generateToken(userTokenDTO);
}else {
//2.根据浙政钉的员工钉钉号查询是否有这个人
// List<ThirdPartyUserEntity> thirdPartyUserEntityList = thirdPartyUserService.getUserByEmployeeCode(employeeCode);
// UserDTO userDTO = new UserDTO();
// //写死,新加入到部门表的全部归类到其他部门里面,不按照浙政钉的部门
// userDTO.setDeptId(1865990541445263361L);
// userDTO.setUsername(zzdUserDTO.getUsername());
// userDTO.setName(zzdUserDTO.getUsername());
// userDTO.setNickname(zzdUserDTO.getUsername());
// //浙里办用户
// userDTO.setSystemType("1");
// //取上面的手机号
// userDTO.setPhone(mobile);
// userDTO.setDingtalkEmployeeCode(employeeCode);
// //随便写死给个密码
// userDTO.setPassword("pj"+mobile);
// if(!CollectionUtils.isEmpty(thirdPartyUserEntityList)){
// //todo 这一步现在是没用的,这个最终要考虑是否通过浙政钉里面的人员来限制移动端的使用用户来决定
// //第三方数据库里面有
// //这里只在第三方库里查到,所以要对系统用户表进行新增当前用户,插入一条信息进去
// R<Boolean> booleanR = remoteUserService.registerUser(userDTO);
// //通过唯一手机号查询到刚注册的用户信息,生成token
// R<List<SysUser>> userByMobile=remoteUserService.getUserByMobile(mobile);
// SysUser sysUser = userByMobile.getData().get(0);
// UserTokenDTO userTokenDTO = new UserTokenDTO();
// userTokenDTO.setSysUser(sysUser);
// //登录成功,根据新注册的用户信息返回token
// r=remoteTokenService.generateToken(userTokenDTO);
// }else {
// return R.failed("没有找到当前用户信息,无法访问!");
// }
return R.failed("系统中没有当前用户信息,请联系管理员进行申请!");
}
//两个表都没查到人员不能够使用移动端的系统
// log.info("用户不存在,尝试创建用户");
// R<Boolean> booleanR = remoteUserService.registerUser(userDTO);
// //通过唯一手机号查询到刚注册的用户信息,生成token
//
// R<List<SysUser>> userByMobile=remoteUserService.getUserByMobile(mobile);
// SysUser sysUser = userByMobile.getData().get(0);
//// int i=8/0;
// UserTokenDTO userTokenDTO = new UserTokenDTO();
// userTokenDTO.setSysUser(sysUser);
// //登录成功,根据新注册的用户信息返回token
// R<Map> r = remoteTokenService.generateToken(userTokenDTO);
HashMap<String, Object> objectObjectHashMap = new HashMap<>();
Object accessToken = r.getData().get("accessToken");
Object refreshToken = r.getData().get("refreshToken");
objectObjectHashMap.put("refreshToken",refreshToken);
objectObjectHashMap.put("accessToken",accessToken);
return R.ok(objectObjectHashMap);
}
/**
* 诗画sdk获取参数
*
* @param
* @return
*/
@PostMapping("/shjnsso")
@Inner(value = false)
@Operation(summary = "诗画江南单点登录")
@SysLog("诗画江南单点登录")
@Transactional
@GlobalTransactional
public R shjnsso(@RequestBody @Valid SHJNSSODTO shjnsso,HttpServletRequest httpServletRequest) {
//检查设备指纹
ZZDUserDTO zzdUserDTO = thirdPartyOAuth2Service.shjnsso(shjnsso.getAccountId(), shjnsso.getSessionId());
if (null==zzdUserDTO){
throw new RuntimeException("");
}
ZZDUserDTO.Ext ext = zzdUserDTO.getExt();
String mobile = zzdUserDTO.getMobile();
R<List<SysUser>> userByMobileOrEmployeeCode = remoteUserService.getUserByMobileOrEmployeeCode(null, mobile);
List<SysUser> sysUserList = userByMobileOrEmployeeCode.getData();
if(!CollectionUtils.isEmpty(sysUserList)){
SysUser sysUser = sysUserList.get(0);
//判断当前用户设备是否信任
String md5 = DeviceFingerprintUtil.generate(httpServletRequest);
R<UserTrustedDeviceEntity> userTrustedDeviceR = remoteUserTrustedDeviceService.getUserTrustedDeviceByIdAndMD5(sysUser.getPhone(), md5);
UserTrustedDeviceEntity userTrustedDevice = userTrustedDeviceR.getData();
if (userTrustedDevice == null || userTrustedDevice.getExpireTime().isBefore(LocalDateTime.now())){
return R.failed(-1,"请先信任设备");
}
//登录成功生成token
UserTokenDTO userTokenDTO = new UserTokenDTO();
userTokenDTO.setSysUser(sysUser);
return remoteTokenService.generateToken(userTokenDTO);
}else {
//自动注册
// UserDTO userDTO = new UserDTO();
//// userDTO.setDeptId(1865990541445263361L);
// userDTO.setName(zzdUserDTO.getUsername());
// userDTO.setNickname(zzdUserDTO.getUsername());
// userDTO.setSystemType("2");
// userDTO.setPhone(mobile);
// userDTO.setCreateBy("诗画自动创建");
// //赋予随机密码
// userDTO.setPassword("pj*"+ UUID.randomUUID());
// R<Boolean> booleanR = remoteUserService.registerUser(userDTO);
// R<List<SysUser>> userByMobile=remoteUserService.getUserByMobile(mobile);
// SysUser sysUser = userByMobile.getData().get(0);
// UserTokenDTO userTokenDTO = new UserTokenDTO();
// userTokenDTO.setSysUser(sysUser);
//// //登录成功,根据新注册的用户信息返回token
// r=remoteTokenService.generateToken(userTokenDTO);
return R.failed("系统中没有当前用户信息,请联系管理员进行申请!");
}
}
@Inner(value = false)
@GetMapping("/getAuthToken/{token}")
public R info(@PathVariable(name = "token") String token) {
//调用第三方获取基础信息与数据库进行匹配
// 拼接请求参数
UserTokenDTO userTokenDTO = new UserTokenDTO();
userTokenDTO.setUsername("admin"); // 只需要用户名,就可以生成 token
userTokenDTO.setAuthorities(List.of("sys_user_add")); // 权限列表的字符串,能调用的接口范围
// feign 调用 auth 模块生成 token
R<Map> authenticationTokenR = remoteTokenService.generateToken(userTokenDTO);
// // 客户端信息
// RegisteredClient registeredClient = data.getRegisteredClient();
Object accessToken = authenticationTokenR.getData().get("accessToken");
Object refreshToken = authenticationTokenR.getData().get("refreshToken");
Map<String, Object> stringObjectHashMap = new HashMap<>();
stringObjectHashMap.put("accessToken", accessToken);
stringObjectHashMap.put("refreshToken", refreshToken);
return R.ok(stringObjectHashMap);
}
@PostMapping("/verify-device")
@SysLog("设备验证")
@Inner(value = false)
public R verifyDevice(@RequestBody DeviceVerifyDTO dto,HttpServletRequest httpServletRequest) {
// 1. 校验验证码
String redisKey = "smsCode_" + dto.getMobilePhone();
String storedCode = (String) redisTemplate.opsForValue().get(redisKey);
if (!dto.getCode().equals(storedCode)) {
return R.failed("验证码错误");
}
// 验证成功后移除验证码(可选)
redisTemplate.delete(redisKey);
// 2. 记录信任设备
//查询之前的是否有过期的数据,有就更新
String generate = DeviceFingerprintUtil.generate(httpServletRequest);
R<UserTrustedDeviceEntity> userTrustedDeviceR = remoteUserTrustedDeviceService.getUserTrustedDeviceByIdAndMD5(dto.getMobilePhone(), generate);
if (userTrustedDeviceR.getData() != null){
userTrustedDeviceR.getData().setExpireTime(LocalDateTime.now().plusMonths(3));
remoteUserTrustedDeviceService.updateById(userTrustedDeviceR.getData());
}else {
UserTrustedDeviceEntity userTrustedDevice = new UserTrustedDeviceEntity();
userTrustedDevice.setMobilePhone(dto.getMobilePhone());
userTrustedDevice.setExpireTime(LocalDateTime.now().plusMonths(3));
userTrustedDevice.setDeviceName("诗画设备认证");
userTrustedDevice.setDeviceFingerprint(generate);
remoteUserTrustedDeviceService.save(userTrustedDevice);
}
return R.ok();
}
/**
* 诗画浦江端
* @param mobilePhone
* @param httpServletRequest
* @param type 短信区分标识 if null 诗画登录短信 1 访客登录短信
* @return
*/
@PostMapping("/loginSms")
@Operation(summary = "发送登录短信")
@SysLog("发送登录短信")
@Inner(value = false)
public R loginSms(@RequestParam String mobilePhone,HttpServletRequest httpServletRequest,
@RequestParam(value = "type",required = false)Integer type) {
Map<String, String> stringHashMap = new LinkedHashMap<>();
String s = SmsCodeUtil.generateNumberCode(6);
stringHashMap.put("code",s);
stringHashMap.put("date","5");
smsService.sendSms(mobilePhone, "20657",stringHashMap, WebUtils.getIP());
// null 适配之前诗画的,=1访客,先else
if (null==type){
redisTemplate.opsForValue().set(CacheConstants.SMS_CODE_KEY +mobilePhone,s, 5, TimeUnit.MINUTES);
}else {
stringRedisTemplate.opsForValue().set(CacheConstants.SMS_VISITOR_CODE_KEY +mobilePhone,s, 5, TimeUnit.MINUTES);
}
// redisTemplate.opsForValue().set(CacheConstants.SMS_CODE_KEY +mobilePhone,s, 5, TimeUnit.MINUTES);
return R.ok();
}
@Inner(value = false)
@GetMapping("/getAuthToken/{token}")
public R info(@PathVariable(name = "token") String token) {
UserTokenDTO userTokenDTO = new UserTokenDTO();
userTokenDTO.setUsername("admin");
userTokenDTO.setAuthorities(List.of("sys_user_add"));
R<Map> authenticationTokenR = remoteTokenService.generateToken(userTokenDTO);
Object accessToken = authenticationTokenR.getData().get("accessToken");
Object refreshToken = authenticationTokenR.getData().get("refreshToken");
Map<String, Object> stringObjectHashMap = new HashMap<>();
stringObjectHashMap.put("accessToken", accessToken);
stringObjectHashMap.put("refreshToken", refreshToken);
return R.ok(stringObjectHashMap);
}
@PostMapping("/verify-device")
@SysLog("设备验证")
@Inner(value = false)
public R verifyDevice(@RequestBody DeviceVerifyDTO dto, HttpServletRequest httpServletRequest) {
String redisKey = "smsCode_" + dto.getMobilePhone();
String storedCode = (String) redisTemplate.opsForValue().get(redisKey);
if (!dto.getCode().equals(storedCode)) {
return R.failed("验证码错误");
}
redisTemplate.delete(redisKey);
String generate = DeviceFingerprintUtil.generate(httpServletRequest);
R<UserTrustedDeviceEntity> userTrustedDeviceR =
remoteUserTrustedDeviceService.getUserTrustedDeviceByIdAndMD5(dto.getMobilePhone(), generate);
if (userTrustedDeviceR.getData() != null) {
userTrustedDeviceR.getData().setExpireTime(LocalDateTime.now().plusMonths(3));
remoteUserTrustedDeviceService.updateById(userTrustedDeviceR.getData());
}
else {
UserTrustedDeviceEntity userTrustedDevice = new UserTrustedDeviceEntity();
userTrustedDevice.setMobilePhone(dto.getMobilePhone());
userTrustedDevice.setExpireTime(LocalDateTime.now().plusMonths(3));
userTrustedDevice.setDeviceName("设备认证");
userTrustedDevice.setDeviceFingerprint(generate);
remoteUserTrustedDeviceService.save(userTrustedDevice);
}
return R.ok();
}
/**
* @param type 短信区分:null 登录短信;1 访客登录短信
*/
@PostMapping("/loginSms")
@Operation(summary = "发送登录短信")
@SysLog("发送登录短信")
@Inner(value = false)
public R loginSms(@RequestParam String mobilePhone, HttpServletRequest httpServletRequest,
@RequestParam(value = "type", required = false) Integer type) {
Map<String, String> stringHashMap = new LinkedHashMap<>();
String s = SmsCodeUtil.generateNumberCode(6);
stringHashMap.put("code", s);
stringHashMap.put("date", "5");
smsService.sendSms(mobilePhone, "20657", stringHashMap, WebUtils.getIP());
if (null == type) {
redisTemplate.opsForValue().set(CacheConstants.SMS_CODE_KEY + mobilePhone, s, 5, TimeUnit.MINUTES);
}
else {
stringRedisTemplate.opsForValue()
.set(CacheConstants.SMS_VISITOR_CODE_KEY + mobilePhone, s, 5, TimeUnit.MINUTES);
}
return R.ok();
}
}
@@ -1,31 +1,19 @@
package com.bsd.pjcloud.thirdparty.service;
import com.alibaba.fastjson.JSON;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.thirdparty.config.OAuth2ClientConfig;
import com.bsd.pjcloud.thirdparty.constant.UserTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.*;
import com.bsd.pjcloud.thirdparty.entity.ThirdPartyDeptEntity;
import com.bsd.pjcloud.thirdparty.entity.ThirdPartyLogEntity;
import com.bsd.pjcloud.thirdparty.entity.ThirdPartyUserEntity;
import com.bsd.pjcloud.thirdparty.exception.ThirdPartyException;
import com.bsd.pjcloud.thirdparty.shpj.ShpjOpenapiUser;
import com.bsd.pjcloud.thirdparty.shpj.ShpjUserClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.util.*;
@@ -49,14 +37,10 @@ public class ThirdPartyOAuth2Service {
private final int MAX_PAGE_SIZE = 100; // 每次最多获取 100 条
private final ThirdPartyDeptService partyDeptService;
private final ThirdPartyDeptService partyDeptService;
private final ThirdPartyUserService partyUserService;
private final ThirdPartyLogService thirdPartyLogService;
private final ShpjUserClient shpjUserClient;
public TokenDTO getAccessToken() {
TokenDTO cachedToken = tokenCache.get("access_token");
@@ -357,121 +341,4 @@ public class ThirdPartyOAuth2Service {
return response.getBody();
}
/**
* 获取浙政钉的token
* @param token
* @return
*/
public ZZDUserDTO zzdsso(String token) {
// String zzdToken = getZZDToken(token);
//todo 根据是诗画还是浙政钉分别去获取不同的token
//使用token获取当前登录人的信息(浙政钉和诗画的在一起)
// ZZDUserDTO zzdUser = getZZDUserInfo(zzdToken);
ZZDUserDTO zzdUser = getZZDUserInfo(token,UserTypeEnum.ZZD.getType());
return zzdUser;
}
public String getZZDToken(String token) {
String url = baseUrl+config.getZzdTokenUrl()+token;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> requestEntity = new HttpEntity<>(null, headers);
ResponseEntity<ZZDResponse> response = restTemplate.exchange(
url, HttpMethod.POST, requestEntity, ZZDResponse.class
);
String zzdToken = response.getBody().getData();
return zzdToken;
}
/**
* 浙证钉和诗画工用
* @param zzdToken
* @return
*/
public ZZDUserDTO getZZDUserInfo(String zzdToken,String type) {
// 构建请求URL
try {
String url = baseUrl + config.getZzdShUserInfoUrl()+"?token=" + zzdToken;
// 获取 Bearer Token
String accessToken = getAccessToken().getAccess_token();
// 设置请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
// 构建请求实体
// 发送请求
ParameterizedTypeReference<ApiResponse<ZZDUserDTO>> responseType = new ParameterizedTypeReference<ApiResponse<ZZDUserDTO>>() {};
ResponseEntity<ApiResponse<ZZDUserDTO>> response = restTemplate.exchange(
url, HttpMethod.GET, requestEntity, responseType
);
// if (true){
if (response.getBody().getCode().equals("-1")){
log.error("获取浙政钉/诗画用户信息失败");
log.error("token",zzdToken);
log.error("response",JSON.toJSONString(response.getBody()));
ThirdPartyLogEntity thirdPartyLogEntity = new ThirdPartyLogEntity();
if (type.equals(UserTypeEnum.ZZD.getType())){
thirdPartyLogEntity.setApiName(UserTypeEnum.ZZD.getDescription());
}else if (type.equals(UserTypeEnum.SH.getType())){
thirdPartyLogEntity.setApiName(UserTypeEnum.SH.getDescription());
}
// thirdPartyLogEntity.setApiName("获取浙政钉/诗画用户信息失败");
thirdPartyLogEntity.setRequestUrl( baseUrl + config.getZzdShUserInfoUrl());
thirdPartyLogEntity.setRequestParameter(zzdToken);
thirdPartyLogEntity.setResponseBody(JSON.toJSONString(response.getBody()));
thirdPartyLogEntity.setErrorCode(response.getBody().getCode());
thirdPartyLogEntity.setErrorMsg(response.getBody().getDescription());
thirdPartyLogEntity.setHttpStatus(response.getStatusCode().toString());
thirdPartyLogEntity.setCreateBy("thirdPartylog");
thirdPartyLogService.saveLog(thirdPartyLogEntity);
throw new RuntimeException(response.getBody().getDescription());
}
return response.getBody().getData();
} catch (HttpStatusCodeException e) {
log.error("第三方接口HTTP异常 状态码:{} 响应:{}", e.getRawStatusCode(), e.getResponseBodyAsString());
throw new ThirdPartyException("第三方服务异常,请联系管理员", "THIRD_PARTY_500");
} catch (ResourceAccessException e) {
log.error("第三方接口网络异常", e);
throw new ThirdPartyException("第三方服务暂时不可用,请稍后重试", "NETWORK_ERROR");
}
}
/**
* 诗画浦江单点:直连诗画开放接口获取用户,不再经对方 /login/shpj 中转
*/
public ZZDUserDTO shjnsso(String accountId, String sessionId) {
ShpjOpenapiUser openapiUser = shpjUserClient.getUserInfo(accountId, sessionId);
if (openapiUser == null || openapiUser.getIsLogin() != 1) {
throw new ThirdPartyException("请先登录诗画客户端", "SHPJ_NOT_LOGIN");
}
if (!StringUtils.hasText(openapiUser.getMobile())) {
throw new ThirdPartyException("诗画用户手机号为空", "SHPJ_MOBILE_EMPTY");
}
return mapShpjOpenapiUserToZzdDto(openapiUser);
}
private static ZZDUserDTO mapShpjOpenapiUserToZzdDto(ShpjOpenapiUser user) {
ZZDUserDTO dto = new ZZDUserDTO();
dto.setUsername(user.getNickName());
dto.setNickName(user.getNickName());
dto.setMobile(user.getMobile());
ZZDUserDTO.Ext ext = dto.new Ext();
ext.setNickNameCn(user.getNickName());
ext.setMobile(user.getMobile());
ext.setHeadImgUrl(user.getHeadImgUrl());
dto.setExt(ext);
return dto;
}
}
@@ -1,17 +0,0 @@
package com.bsd.pjcloud.thirdparty.shpj;
import lombok.Data;
@Data
public class ShpjApiWrapper<T> {
private int code;
private T data;
private String message;
public boolean isSuccessful() {
return code == 0;
}
}
@@ -1,42 +0,0 @@
package com.bsd.pjcloud.thirdparty.shpj;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
/**
* 诗画浦江开放接口用户信息(对齐对方 OpenapiUser)
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ShpjOpenapiUser {
private int is_faceVerify;
private String city;
private String nickName;
private boolean forbidden;
private String mobile;
private String deviceId;
private boolean freezed;
private String accountId;
private int isLogin;
private String createdAt;
private String headImgUrl;
private String clientCode;
private String recentActiveTime;
private int grade;
private String refCode;
}
@@ -1,24 +0,0 @@
package com.bsd.pjcloud.thirdparty.shpj;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 诗画浦江开放接口配置(直连 vcms,不经对方中转服务)
*/
@Data
@Component
@ConfigurationProperties(prefix = "pjcloud.shpj")
public class ShpjProperties {
private String appKey;
private String appSecret;
private String connectNo;
private String baseUrl = "https://vcms.tmuyun.com";
private int httpTimeoutSeconds = 5;
}
@@ -1,38 +0,0 @@
package com.bsd.pjcloud.thirdparty.shpj;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* 诗画浦江签名:timestamp&&appkey&&appsecret&&noncestr&&paramValues
*/
public final class ShpjSignHelper {
private ShpjSignHelper() {
}
public static String sign(Map<String, String> businessParams, String timestamp, String appKey, String appSecret, String nonce) {
String raw = String.format("%s&&%s&&%s&&%s", timestamp, appKey, appSecret, nonce) + extraSign(businessParams);
return DigestUtils.md5DigestAsHex(raw.getBytes(StandardCharsets.UTF_8));
}
private static String extraSign(Map<String, String> paramsMap) {
SortedMap<String, String> resMap = new TreeMap<>(paramsMap);
String[] keys = resMap.keySet().toArray(new String[0]);
Arrays.sort(keys);
StringBuilder paramBuffer = new StringBuilder();
for (String key : keys) {
String value = resMap.get(key);
if (StringUtils.hasLength(value) && !"0".equals(value)) {
paramBuffer.append("&&").append(value);
}
}
return paramBuffer.toString();
}
}
@@ -1,85 +0,0 @@
package com.bsd.pjcloud.thirdparty.shpj;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.bsd.pjcloud.thirdparty.exception.ThirdPartyException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map;
/**
* 诗画浦江用户开放接口客户端(直连)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ShpjUserClient {
private final ShpjProperties properties;
private final ObjectMapper objectMapper;
/**
* 按 accountId + sessionId 查询诗画用户详情
*/
public ShpjOpenapiUser getUserInfo(String accountId, String sessionId) {
if (!StringUtils.hasText(accountId) || "-1".equals(accountId)) {
throw new ThirdPartyException("请先登录诗画客户端", "SHPJ_NOT_LOGIN");
}
if (!StringUtils.hasText(sessionId)) {
throw new ThirdPartyException("sessionId不能为空", "SHPJ_SESSION_EMPTY");
}
if (!StringUtils.hasText(properties.getAppKey()) || !StringUtils.hasText(properties.getAppSecret())
|| !StringUtils.hasText(properties.getConnectNo())) {
throw new ThirdPartyException("诗画浦江开放接口未配置", "SHPJ_CFG");
}
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = timestamp + "shpj";
Map<String, String> businessParams = new HashMap<>(2);
businessParams.put("sessionId", sessionId);
businessParams.put("connectNo", properties.getConnectNo());
Map<String, Object> form = new HashMap<>();
form.put("appkey", properties.getAppKey());
form.put("noncestr", nonce);
form.put("timestamp", timestamp);
form.put("signature", ShpjSignHelper.sign(businessParams, timestamp, properties.getAppKey(), properties.getAppSecret(), nonce));
form.putAll(businessParams);
String apiUrl = "/openapi/v2/" + accountId + "/detail";
String fullUrl = properties.getBaseUrl() + apiUrl;
log.info("调用诗画浦江接口[{}]开始, accountId={}, sessionId={}", apiUrl, accountId, sessionId);
try (HttpResponse httpResponse = HttpRequest.get(fullUrl)
.timeout(properties.getHttpTimeoutSeconds() * 1000)
.form(form)
.execute()) {
String body = httpResponse.body();
if (!httpResponse.isOk()) {
log.error("诗画浦江接口[{}]HTTP错误:{} - {}", apiUrl, httpResponse.getStatus(), body);
throw new ThirdPartyException("诗画浦江接口调用失败", "SHPJ_HTTP");
}
log.info("诗画浦江接口[{}]完成, 返回:{}", apiUrl, body);
ShpjApiWrapper<ShpjOpenapiUser> resp = objectMapper.readValue(body,
objectMapper.getTypeFactory().constructParametricType(ShpjApiWrapper.class, ShpjOpenapiUser.class));
if (!resp.isSuccessful()) {
log.error("诗画浦江接口[{}]业务失败: [{}-{}]", apiUrl, resp.getCode(), resp.getMessage());
throw new ThirdPartyException("诗画浦江接口调用失败:" + resp.getMessage(), "SHPJ_BIZ_" + resp.getCode());
}
return resp.getData();
} catch (ThirdPartyException e) {
throw e;
} catch (Exception e) {
log.error("诗画浦江接口[{}]异常", apiUrl, e);
throw new ThirdPartyException("诗画浦江服务暂时不可用", "SHPJ_ERROR");
}
}
}
@@ -34,19 +34,6 @@ oauth2:
dept-url: /prod-api/oauth2/apis/orguser/v1/list_sub_depts
dept-users-url: /prod-api/oauth2/apis/orguser/v1/list_dept_users
user_detail-url: /prod-api/oauth2/apis/orguser/v1/user_detail
# 数字政务单点接口
zzd_token_url: /prod-api/login/szzw/
# 浙证钉和诗画公用的获取用户信息接口(诗画已改为本系统直连 vcms)
zzd_sh_user_info_url: /prod-api/oauth2/apis/sso/v1/fetch_user_by_uabase_token
# 诗画浦江开放接口(直连 https://vcms.tmuyun.com,不经对方 /login/shpj)
pjcloud:
shpj:
app-key: ${SHPJ_APP_KEY:rQZzIvB7beaeZBf2IbQnemYv}
app-secret: ${SHPJ_APP_SECRET:4d248cd64bc5653fd2c1d6bb425b2817}
connect-no: ${SHPJ_CONNECT_NO:afb7e8fbf5f4eb9f}
base-url: ${SHPJ_BASE_URL:https://vcms.tmuyun.com}
http-timeout-seconds: 5
# 正元智慧
# https://gwyc.zj.gov.cn/API/%E7%9C%81%E5%B9%B3%E5%8F%B0API%E5%AF%B9%E6%8E%A5%E6%8E%A5%E5%8F%A3.html
@@ -263,9 +263,9 @@ public class SysUser extends Model<SysUser> implements Serializable {
private String email;
/**
* 员工浙政钉编码
* 员工钉钉编码
*/
@Schema(description = "员工浙政钉编码")
@Schema(description = "员工钉钉编码")
private String dingtalkEmployeeCode;
/**
@@ -256,7 +256,7 @@ public class UserVO implements BaseFacesInterface, BaseUserIdentityInterface, Se
@Schema(description = "邮箱")
private String email;
@Schema(description = "员工浙政钉编码")
@Schema(description = "员工钉钉编码")
private String dingtalkEmployeeCode;
/**
@@ -834,10 +834,10 @@ public class SysUserController {
}
/**
* 根据浙政钉号和手机号查询
* 根据员工钉钉编码和手机号查询
*
* @param employeeCode
* @param mobile
* @param employeeCode 员工钉钉编码(可空)
* @param mobile 手机号
* @return
*/
@GetMapping("/getUserByMobileOrEmployeeCode")