From 1845f5865c8fdd6d8d4c35be9ba933391adab3dc Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Mon, 28 Sep 2026 22:20:09 +0800 Subject: [PATCH 01/13] =?UTF-8?q?1.=E7=A7=BB=E9=99=A4=E6=B5=99=E6=94=BF?= =?UTF-8?q?=E9=92=89=E8=AF=97=E7=94=BB=E4=BB=A3=E7=A0=81=202.=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E9=85=92=E5=BA=97=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppUserAutoLoginController.java | 350 +------------ .../canteen/entity/CanteenHotelEntity.java | 66 +++ .../vo/CanteenHotelImportResultVO.java | 45 ++ .../controller/CanteenHotelController.java | 91 ++++ .../canteen/mapper/CanteenHotelMapper.java | 14 + .../canteen/service/CanteenHotelService.java | 26 + .../service/impl/CanteenHotelServiceImpl.java | 493 ++++++++++++++++++ .../resources/mapper/CanteenHotelMapper.xml | 18 + .../feign/RemoterThirdPartyAuthService.java | 46 +- .../thirdparty/constant/UserTypeEnum.java | 21 - .../pjcloud/thirdparty/dto/SHJNSSODTO.java | 14 - .../pjcloud/thirdparty/dto/UrlTokenDTO.java | 9 - .../pjcloud/thirdparty/dto/ZZDResponse.java | 11 - .../bsd/pjcloud/thirdparty/dto/ZZDUser.java | 17 - .../pjcloud/thirdparty/dto/ZZDUserDTO.java | 28 - .../entity/ThirdPartyUserEntity.java | 8 +- .../thirdparty/config/OAuth2ClientConfig.java | 11 - .../controller/ThirdPartyOAth2Cotroller.java | 381 +++----------- .../service/ThirdPartyOAuth2Service.java | 135 +---- .../thirdparty/shpj/ShpjApiWrapper.java | 17 - .../thirdparty/shpj/ShpjOpenapiUser.java | 42 -- .../thirdparty/shpj/ShpjProperties.java | 24 - .../thirdparty/shpj/ShpjSignHelper.java | 38 -- .../thirdparty/shpj/ShpjUserClient.java | 85 --- .../src/main/resources/application.yml | 13 - .../bsd/pjcloud/admin/api/entity/SysUser.java | 4 +- .../com/bsd/pjcloud/admin/api/vo/UserVO.java | 2 +- .../admin/controller/SysUserController.java | 6 +- 28 files changed, 855 insertions(+), 1160 deletions(-) create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelImportResultVO.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelService.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelServiceImpl.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelMapper.xml delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/UserTypeEnum.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/SHJNSSODTO.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/UrlTokenDTO.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDResponse.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDUser.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDUserDTO.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjApiWrapper.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjOpenapiUser.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjProperties.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjSignHelper.java delete mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjUserClient.java diff --git a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppUserAutoLoginController.java b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppUserAutoLoginController.java index 04ee43189..2b21d2732 100644 --- a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppUserAutoLoginController.java +++ b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppUserAutoLoginController.java @@ -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> userByMobileOrEmployeeCode = remoteUserService.getUserByMobileOrEmployeeCode(employeeCode, mobile); - List sysUserList = userByMobileOrEmployeeCode.getData(); - - if(!CollectionUtils.isEmpty(sysUserList)){ - //登录成功生成token - UserTokenDTO userTokenDTO = new UserTokenDTO(); - userTokenDTO.setSysUser(sysUserList.get(0)); - remoteTokenService.generateToken(userTokenDTO); - } - //2.根据浙政钉的员工钉钉号查询是否有这个人 - R> thirdPartyUserEntityR=remoterThirdPartyAuthService.getUserByEmployeeCode(employeeCode); - List 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 booleanR = remoteUserService.registerUser(userDTO); - //通过唯一手机号查询到刚注册的用户信息,生成token - R> 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 booleanR = remoteUserService.registerUser(userDTO); - //通过唯一手机号查询到刚注册的用户信息,生成token - - R> 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> rListSysUser = remoteUserService.getUserByMobileOrEmployeeCode(null, mobile); - List 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 authenticationTokenR = remoteTokenService.generateToken(userTokenDTO); -// // 客户端信息 -// RegisteredClient registeredClient = data.getRegisteredClient(); Object accessToken = authenticationTokenR.getData().get("accessToken"); Object refreshToken = authenticationTokenR.getData().get("refreshToken"); Map stringObjectHashMap = new HashMap<>(); @@ -257,142 +50,17 @@ public class AppUserAutoLoginController { return R.ok(stringObjectHashMap); } - @GetMapping(value = { "/test" }) @Inner(value = false) public R info() { - -// R> spuListByIds = remoteOnlineCanteenSpuService.getSpuListByIds(Arrays.asList(1876870696924069890L)); - -// R> spuListByIds = remoteCanteenSpuService.getSpuListByIds(Arrays.asList(1876517188510273537L, 1866411313032441857L)); - -// List data = spuListByIds.getData(); - Page 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 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 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 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); -// } } diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java new file mode 100644 index 000000000..157cc11e0 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelEntity extends Model { + + @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 = "备注") + private String remark; + + @TableField(fill = FieldFill.INSERT) + private String createBy; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + @TableField(fill = FieldFill.INSERT) + private String delFlag; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelImportResultVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelImportResultVO.java new file mode 100644 index 000000000..669f061de --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelImportResultVO.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelImportResultVO { + + @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 sheetNames = new ArrayList<>(); + + @Schema(description = "跳过样例(最多50条)") + private List skipSamples = new ArrayList<>(); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java new file mode 100644 index 000000000..8b3688885 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java @@ -0,0 +1,91 @@ +package com.bsd.pjcloud.canteen.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.canteen.entity.CanteenHotelEntity; +import com.bsd.pjcloud.canteen.service.CanteenHotelService; +import com.bsd.pjcloud.canteen.vo.CanteenHotelImportResultVO; +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("/canteenHotel") +@Tag(name = "酒店管理") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class CanteenHotelController { + + private final CanteenHotelService hotelService; + + @Operation(summary = "分页查询") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R> page(@ParameterObject Page page, + @ParameterObject CanteenHotelEntity query) { + var wrapper = Wrappers.lambdaQuery() + .like(StrUtil.isNotBlank(query.getHotelName()), CanteenHotelEntity::getHotelName, query.getHotelName()) + .like(StrUtil.isNotBlank(query.getHotelCode()), CanteenHotelEntity::getHotelCode, query.getHotelCode()) + .like(StrUtil.isNotBlank(query.getGroupCode()), CanteenHotelEntity::getGroupCode, query.getGroupCode()) + .eq(StrUtil.isNotBlank(query.getSourceId()), CanteenHotelEntity::getSourceId, query.getSourceId()) + .eq(StrUtil.isNotBlank(query.getSheetName()), CanteenHotelEntity::getSheetName, query.getSheetName()) + .orderByDesc(CanteenHotelEntity::getCreateTime) + .orderByDesc(CanteenHotelEntity::getId); + return R.ok(hotelService.pageScope(page, wrapper)); + } + + @Operation(summary = "详情") + @GetMapping("/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R getById(@PathVariable Long id) { + return R.ok(hotelService.getById(id)); + } + + @SysLog("新增酒店") + @Operation(summary = "新增") + @PostMapping + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_add')") + public R save(@RequestBody CanteenHotelEntity entity) { + return R.ok(hotelService.saveHotel(entity)); + } + + @SysLog("修改酒店") + @Operation(summary = "修改") + @PutMapping + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + public R update(@RequestBody CanteenHotelEntity entity) { + return R.ok(hotelService.updateHotel(entity)); + } + + @SysLog("删除酒店") + @Operation(summary = "删除") + @DeleteMapping + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_del')") + public R remove(@RequestBody Long[] ids) { + return R.ok(hotelService.removeHotels(ids)); + } + + /** 上传文件会立刻 transferTo 临时目录,勿加 @SysLog,否则会序列化已删除的 Undertow 临时文件报 NoSuchFileException */ + @Operation(summary = "提交Excel异步导入(多Sheet流式)") + @PostMapping("/import") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_import')") + public R importExcel(@RequestParam("file") MultipartFile file) { + return hotelService.startImport(file); + } + + @Operation(summary = "查询酒店导入进度") + @GetMapping("/import/status/{taskId}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_import')") + public R importStatus(@PathVariable String taskId) { + return hotelService.getImportStatus(taskId); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelMapper.java new file mode 100644 index 000000000..6cf0a0d10 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelMapper.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelEntity; +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 CanteenHotelMapper extends PjcloudBaseMapper { + + int insertBatch(@Param("list") List list); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelService.java new file mode 100644 index 000000000..80a28693e --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelService.java @@ -0,0 +1,26 @@ +package com.bsd.pjcloud.canteen.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.canteen.entity.CanteenHotelEntity; +import com.bsd.pjcloud.canteen.vo.CanteenHotelImportResultVO; +import com.bsd.pjcloud.common.core.util.R; +import org.springframework.web.multipart.MultipartFile; + +public interface CanteenHotelService extends IService { + + Page pageScope(Page page, LambdaQueryWrapper wrapper); + + Boolean saveHotel(CanteenHotelEntity entity); + + Boolean updateHotel(CanteenHotelEntity entity); + + Boolean removeHotels(Long[] ids); + + /** 提交异步导入,立刻返回 taskId(status=RUNNING) */ + R startImport(MultipartFile file); + + /** 查询导入任务进度 */ + R getImportStatus(String taskId); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelServiceImpl.java new file mode 100644 index 000000000..bcecc62d9 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelServiceImpl.java @@ -0,0 +1,493 @@ +package com.bsd.pjcloud.canteen.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.canteen.entity.CanteenHotelEntity; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelMapper; +import com.bsd.pjcloud.canteen.service.CanteenHotelService; +import com.bsd.pjcloud.canteen.vo.CanteenHotelImportResultVO; +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 CanteenHotelServiceImpl extends ServiceImpl + implements CanteenHotelService { + + /** 多值 INSERT 一批条数(与人脸明细导入同量级) */ + private static final int BATCH = 1500; + private static final int SKIP_SAMPLE_LIMIT = 50; + private static final ConcurrentHashMap IMPORT_TASKS = new ConcurrentHashMap<>(); + + @Override + public Page pageScope(Page page, + LambdaQueryWrapper wrapper) { + return baseMapper.selectPageByScope(page, wrapper, DataScope.of()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean saveHotel(CanteenHotelEntity 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(CanteenHotelEntity entity) { + if (entity.getId() == null) { + throw new IllegalArgumentException("酒店ID不能为空"); + } + CanteenHotelEntity 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 startImport(MultipartFile file) { + if (file == null || file.isEmpty()) { + return R.failed("请上传酒店 Excel"); + } + String taskId = IdUtil.getSnowflakeNextIdStr(); + CanteenHotelImportResultVO vo = new CanteenHotelImportResultVO(); + vo.setTaskId(taskId); + vo.setStatus("RUNNING"); + vo.setMessage("已提交,后台导入中…"); + IMPORT_TASKS.put(taskId, vo); + + File temp; + try { + temp = Files.createTempFile("canteen-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); + } + } + }, "canteen-hotel-import-" + taskId); + worker.setDaemon(false); + worker.start(); + return R.ok(vo, "已提交导入,请稍候查看进度"); + } + + @Override + public R getImportStatus(String taskId) { + if (StrUtil.isBlank(taskId)) { + return R.failed("taskId 不能为空"); + } + CanteenHotelImportResultVO 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, CanteenHotelImportResultVO result, Long deptId, Long tenantId, + String operator) throws Exception { + long t0 = System.currentTimeMillis(); + List sheetMetas = listSheetMetas(tempFile); + if (CollUtil.isEmpty(sheetMetas)) { + result.setStatus("FAILED"); + result.setMessage("Excel 中没有任何 Sheet"); + return; + } + result.setSheetCount(sheetMetas.size()); + result.setMessage("预加载已有酒店索引…"); + + Map codeIdMap = new HashMap<>(); + Map sourceIdMap = new HashMap<>(); + preloadIndex(codeIdMap, sourceIdMap); + + List 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 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 codeIdMap, Map sourceIdMap) { + List existing = baseMapper.selectList(Wrappers.lambdaQuery() + .select(CanteenHotelEntity::getId, CanteenHotelEntity::getHotelCode, CanteenHotelEntity::getSourceId)); + for (CanteenHotelEntity 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 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 buffer = ctx.buffer; + if (buffer.isEmpty()) { + return; + } + LocalDateTime now = LocalDateTime.now(); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + for (CanteenHotelEntity 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 slice = toInsert.subList(i, Math.min(i + BATCH, toInsert.size())); + baseMapper.insertBatch(slice); + } + } + if (CollUtil.isNotEmpty(toUpdate)) { + updateBatchById(toUpdate, BATCH); + for (CanteenHotelEntity 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 w = Wrappers.lambdaQuery() + .eq(CanteenHotelEntity::getHotelCode, hotelCode); + if (excludeId != null) { + w.ne(CanteenHotelEntity::getId, excludeId); + } + return baseMapper.selectCount(w) > 0; + } + + private static void normalize(CanteenHotelEntity 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(CanteenHotelImportResultVO 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 CanteenHotelImportResultVO result; + final List buffer; + final Map codeIdMap; + final Map sourceIdMap; + final Long deptId; + final Long tenantId; + final String operator; + + ImportFlushContext(CanteenHotelImportResultVO result, List buffer, + Map codeIdMap, Map 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> { + + private final String sheetName; + private final int sheetIndex; + private final ImportFlushContext ctx; + private final Map 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 headMap, AnalysisContext context) { + ctx.result.setSheetIndex(sheetIndex); + fieldByCol.clear(); + if (headMap == null || headMap.isEmpty()) { + headError = "表头为空"; + return; + } + for (Map.Entry 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 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 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; + } + CanteenHotelEntity entity = new CanteenHotelEntity(); + 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 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); + } + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelMapper.xml new file mode 100644 index 000000000..3fc479726 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelMapper.xml @@ -0,0 +1,18 @@ + + + + + + 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 + + ( + #{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} + ) + + + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdPartyAuthService.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdPartyAuthService.java index c4f00fb7f..81020d6be 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdPartyAuthService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdPartyAuthService.java @@ -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> getUserByEmployeeCode(@RequestParam("employeeCode") String employeeCode); - - } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/UserTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/UserTypeEnum.java deleted file mode 100644 index 1edec6b31..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/UserTypeEnum.java +++ /dev/null @@ -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; - - - -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/SHJNSSODTO.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/SHJNSSODTO.java deleted file mode 100644 index 01606dde4..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/SHJNSSODTO.java +++ /dev/null @@ -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; -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/UrlTokenDTO.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/UrlTokenDTO.java deleted file mode 100644 index 7e2c48744..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/UrlTokenDTO.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.bsd.pjcloud.thirdparty.dto; - - -import lombok.Data; - -@Data -public class UrlTokenDTO { - private String token; -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDResponse.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDResponse.java deleted file mode 100644 index 305375ba8..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDResponse.java +++ /dev/null @@ -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; -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDUser.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDUser.java deleted file mode 100644 index cb75a9539..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDUser.java +++ /dev/null @@ -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; - -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDUserDTO.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDUserDTO.java deleted file mode 100644 index 3d849dc2c..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZZDUserDTO.java +++ /dev/null @@ -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; - - } - -} \ No newline at end of file diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/ThirdPartyUserEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/ThirdPartyUserEntity.java index eced648f3..83be00b9c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/ThirdPartyUserEntity.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/ThirdPartyUserEntity.java @@ -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 { @@ -68,9 +68,9 @@ public class ThirdPartyUserEntity extends Model { private String nickName; /** - * 员工浙政钉编码 + * 员工钉钉编码 */ - @Schema(description="员工浙政钉编码") + @Schema(description="员工钉钉编码") private String dingtalkEmployeeCode; /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/OAuth2ClientConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/OAuth2ClientConfig.java index e6cb7840a..a2b8a1632 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/OAuth2ClientConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/OAuth2ClientConfig.java @@ -23,15 +23,4 @@ public class OAuth2ClientConfig { private String userDetailUrl; - /** - * 浙政钉获取token的接口 - */ - private String zzdTokenUrl; - - /** - * 浙政钉跟诗画的获取用户信息的url(诗画已直连,保留兼容) - */ - private String zzdShUserInfoUrl; - - } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdPartyOAth2Cotroller.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdPartyOAth2Cotroller.java index 193d6a0ba..d2f439424 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdPartyOAth2Cotroller.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdPartyOAth2Cotroller.java @@ -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 r; - //1.根据手机号或者钉钉号先查询是否有这个人 - R> userByMobileOrEmployeeCode = remoteUserService.getUserByMobileOrEmployeeCode(employeeCode, mobile); - List sysUserList = userByMobileOrEmployeeCode.getData(); - if(!CollectionUtils.isEmpty(sysUserList)){ - //登录成功生成token - UserTokenDTO userTokenDTO = new UserTokenDTO(); - userTokenDTO.setSysUser(sysUserList.get(0)); - r = remoteTokenService.generateToken(userTokenDTO); - }else { - //2.根据浙政钉的员工钉钉号查询是否有这个人 -// List 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 booleanR = remoteUserService.registerUser(userDTO); -// //通过唯一手机号查询到刚注册的用户信息,生成token -// R> 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 booleanR = remoteUserService.registerUser(userDTO); -// //通过唯一手机号查询到刚注册的用户信息,生成token -// -// R> userByMobile=remoteUserService.getUserByMobile(mobile); -// SysUser sysUser = userByMobile.getData().get(0); -//// int i=8/0; -// UserTokenDTO userTokenDTO = new UserTokenDTO(); -// userTokenDTO.setSysUser(sysUser); -// //登录成功,根据新注册的用户信息返回token -// R r = remoteTokenService.generateToken(userTokenDTO); - HashMap 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> userByMobileOrEmployeeCode = remoteUserService.getUserByMobileOrEmployeeCode(null, mobile); - List sysUserList = userByMobileOrEmployeeCode.getData(); - if(!CollectionUtils.isEmpty(sysUserList)){ - SysUser sysUser = sysUserList.get(0); - //判断当前用户设备是否信任 - String md5 = DeviceFingerprintUtil.generate(httpServletRequest); - R 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 booleanR = remoteUserService.registerUser(userDTO); -// R> 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 authenticationTokenR = remoteTokenService.generateToken(userTokenDTO); -// // 客户端信息 -// RegisteredClient registeredClient = data.getRegisteredClient(); - Object accessToken = authenticationTokenR.getData().get("accessToken"); - Object refreshToken = authenticationTokenR.getData().get("refreshToken"); - Map 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 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 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 authenticationTokenR = remoteTokenService.generateToken(userTokenDTO); + Object accessToken = authenticationTokenR.getData().get("accessToken"); + Object refreshToken = authenticationTokenR.getData().get("refreshToken"); + Map 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 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 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(); + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPartyOAuth2Service.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPartyOAuth2Service.java index 67c510692..76b68d119 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPartyOAuth2Service.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPartyOAuth2Service.java @@ -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 requestEntity = new HttpEntity<>(null, headers); - ResponseEntity 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 requestEntity = new HttpEntity<>(headers); - // 构建请求实体 - // 发送请求 - ParameterizedTypeReference> responseType = new ParameterizedTypeReference>() {}; - - ResponseEntity> 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; - } } \ No newline at end of file diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjApiWrapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjApiWrapper.java deleted file mode 100644 index da80431d2..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjApiWrapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.bsd.pjcloud.thirdparty.shpj; - -import lombok.Data; - -@Data -public class ShpjApiWrapper { - - private int code; - - private T data; - - private String message; - - public boolean isSuccessful() { - return code == 0; - } -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjOpenapiUser.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjOpenapiUser.java deleted file mode 100644 index 6263ebb3c..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjOpenapiUser.java +++ /dev/null @@ -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; -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjProperties.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjProperties.java deleted file mode 100644 index 036c9f2a8..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjProperties.java +++ /dev/null @@ -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; -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjSignHelper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjSignHelper.java deleted file mode 100644 index 4121e2ea1..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjSignHelper.java +++ /dev/null @@ -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&¶mValues - */ -public final class ShpjSignHelper { - - private ShpjSignHelper() { - } - - public static String sign(Map 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 paramsMap) { - SortedMap 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(); - } -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjUserClient.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjUserClient.java deleted file mode 100644 index fcf7aa955..000000000 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/shpj/ShpjUserClient.java +++ /dev/null @@ -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 businessParams = new HashMap<>(2); - businessParams.put("sessionId", sessionId); - businessParams.put("connectNo", properties.getConnectNo()); - - Map 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 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"); - } - } -} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml index 3616ade56..496aaa3bf 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml @@ -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 diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUser.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUser.java index 9884f7553..d965bca0c 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUser.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUser.java @@ -263,9 +263,9 @@ public class SysUser extends Model implements Serializable { private String email; /** - * 员工浙政钉编码 + * 员工钉钉编码 */ - @Schema(description = "员工浙政钉编码") + @Schema(description = "员工钉钉编码") private String dingtalkEmployeeCode; /** diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserVO.java index f1199f239..3fa50ac13 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserVO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserVO.java @@ -256,7 +256,7 @@ public class UserVO implements BaseFacesInterface, BaseUserIdentityInterface, Se @Schema(description = "邮箱") private String email; - @Schema(description = "员工浙政钉编码") + @Schema(description = "员工钉钉编码") private String dingtalkEmployeeCode; /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index df21687fc..60657d384 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -811,10 +811,10 @@ public class SysUserController { } /** - * 根据浙政钉号和手机号查询 + * 根据员工钉钉编码和手机号查询 * - * @param employeeCode - * @param mobile + * @param employeeCode 员工钉钉编码(可空) + * @param mobile 手机号 * @return */ @GetMapping("/getUserByMobileOrEmployeeCode") From b6f312a364783d6d0aac0b80953deb0e103278e0 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Tue, 29 Sep 2026 21:18:42 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CanteenHotelCallbackTaskEntity.java | 79 ++ .../entity/CanteenHotelDailyEntity.java | 59 + .../CanteenHotelDownstreamAppEntity.java | 50 + .../canteen/entity/CanteenHotelEntity.java | 3 + .../entity/CanteenHotelOrderEntity.java | 108 ++ .../entity/CanteenHotelPriceConfigEntity.java | 45 + .../entity/CanteenHotelProductEntity.java | 80 ++ .../entity/CanteenHotelRoomEntity.java | 57 + .../entity/CanteenHotelSyncLogEntity.java | 85 ++ .../canteen/vo/CanteenHotelDetailVO.java | 28 + .../config/CanteenSchedulingConfig.java | 12 + .../config/HotelDownstreamProperties.java | 31 + .../config/ShangyouHotelProperties.java | 188 +++ .../CanteenHotelCallbackTaskController.java | 51 + .../controller/CanteenHotelController.java | 39 +- ...CanteenHotelDownstreamOrderController.java | 68 ++ .../controller/CanteenHotelMidController.java | 197 ++++ .../CanteenHotelOrderController.java | 67 ++ .../CanteenHotelUpstreamController.java | 134 +++ .../downstream/DownstreamHotelApi.java | 18 + .../downstream/DownstreamHotelClient.java | 97 ++ .../downstream/DownstreamInboundAuth.java | 60 + .../downstream/DownstreamSignHelper.java | 29 + .../shangyou/ShangyouBookStatus.java | 52 + .../shangyou/ShangyouCallbackQueueWorker.java | 94 ++ .../shangyou/ShangyouCallbackSignHelper.java | 21 + .../shangyou/ShangyouHotelApi.java | 60 + .../shangyou/ShangyouHotelClient.java | 703 +++++++++++ .../shangyou/ShangyouOrderCallResult.java | 80 ++ .../ShangyouOrderCompensateWorker.java | 80 ++ .../CanteenHotelShangyouSpringSchedule.java | 58 + .../job/CanteenHotelShangyouSyncJob.java | 56 + .../CanteenHotelCallbackTaskMapper.java | 9 + .../mapper/CanteenHotelDailyMapper.java | 22 + .../CanteenHotelDownstreamAppMapper.java | 9 + .../mapper/CanteenHotelOrderMapper.java | 9 + .../mapper/CanteenHotelPriceConfigMapper.java | 9 + .../mapper/CanteenHotelProductMapper.java | 14 + .../mapper/CanteenHotelRoomMapper.java | 14 + .../mapper/CanteenHotelSyncLogMapper.java | 9 + .../CanteenHotelCallbackTaskService.java | 34 + .../service/CanteenHotelMidService.java | 46 + .../service/CanteenHotelOrderService.java | 43 + .../CanteenHotelPriceConfigService.java | 19 + .../service/CanteenHotelSyncService.java | 57 + .../service/CanteenHotelUpstreamService.java | 45 + .../CanteenHotelCallbackTaskServiceImpl.java | 310 +++++ .../impl/CanteenHotelMidServiceImpl.java | 1023 +++++++++++++++++ .../impl/CanteenHotelOrderServiceImpl.java | 915 +++++++++++++++ .../CanteenHotelPriceConfigServiceImpl.java | 66 ++ .../impl/CanteenHotelSyncServiceImpl.java | 780 +++++++++++++ .../impl/CanteenHotelUpstreamServiceImpl.java | 214 ++++ .../src/main/resources/application.yml | 100 +- .../mapper/CanteenHotelDailyMapper.xml | 19 + .../mapper/CanteenHotelProductMapper.xml | 22 + .../mapper/CanteenHotelRoomMapper.xml | 19 + 56 files changed, 6580 insertions(+), 16 deletions(-) create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelCallbackTaskEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDailyEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDownstreamAppEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelOrderEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelPriceConfigEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelProductEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelRoomEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelSyncLogEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelDetailVO.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/CanteenSchedulingConfig.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/HotelDownstreamProperties.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelCallbackTaskController.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelDownstreamOrderController.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelOrderController.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelUpstreamController.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelApi.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelClient.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamInboundAuth.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamSignHelper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouBookStatus.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackSignHelper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCallResult.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCompensateWorker.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSpringSchedule.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSyncJob.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelCallbackTaskMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDownstreamAppMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelOrderMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelPriceConfigMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelProductMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelRoomMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelSyncLogMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelCallbackTaskService.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelMidService.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelOrderService.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelPriceConfigService.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelSyncService.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelUpstreamService.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelCallbackTaskServiceImpl.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelMidServiceImpl.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelOrderServiceImpl.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelPriceConfigServiceImpl.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelSyncServiceImpl.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelUpstreamServiceImpl.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelDailyMapper.xml create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelProductMapper.xml create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelRoomMapper.xml diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelCallbackTaskEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelCallbackTaskEntity.java new file mode 100644 index 000000000..a103d2b62 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelCallbackTaskEntity.java @@ -0,0 +1,79 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelCallbackTaskEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + @Schema(description = "PRICE / INVENTORY") + 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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDailyEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDailyEntity.java new file mode 100644 index 000000000..8a55dbd92 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDailyEntity.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelDailyEntity extends Model { + + @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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDownstreamAppEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDownstreamAppEntity.java new file mode 100644 index 000000000..fe7615dd5 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDownstreamAppEntity.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelDownstreamAppEntity extends Model { + + @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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java index 157cc11e0..00871b24b 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java @@ -45,6 +45,9 @@ public class CanteenHotelEntity extends Model { @Schema(description = "导入来源Sheet名") private String sheetName; + @Schema(description = "ACTIVE启用 / OFFLINE上游下线停用") + private String upstreamStatus; + @Schema(description = "备注") private String remark; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelOrderEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelOrderEntity.java new file mode 100644 index 000000000..8f1c5d075 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelOrderEntity.java @@ -0,0 +1,108 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelOrderEntity extends Model { + + @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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelPriceConfigEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelPriceConfigEntity.java new file mode 100644 index 000000000..72a54c415 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelPriceConfigEntity.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelPriceConfigEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long tenantId; + + @Schema(description = "加价百分比,例15表示加价15%") + 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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelProductEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelProductEntity.java new file mode 100644 index 000000000..7bf87bb0c --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelProductEntity.java @@ -0,0 +1,80 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelProductEntity extends Model { + + @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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelRoomEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelRoomEntity.java new file mode 100644 index 000000000..d6c3bb0bb --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelRoomEntity.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelRoomEntity extends Model { + + @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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelSyncLogEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelSyncLogEntity.java new file mode 100644 index 000000000..c891971f8 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelSyncLogEntity.java @@ -0,0 +1,85 @@ +package com.bsd.pjcloud.canteen.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 CanteenHotelSyncLogEntity extends Model { + + @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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelDetailVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelDetailVO.java new file mode 100644 index 000000000..149513062 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelDetailVO.java @@ -0,0 +1,28 @@ +package com.bsd.pjcloud.canteen.vo; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelDailyEntity; +import com.bsd.pjcloud.canteen.entity.CanteenHotelEntity; +import com.bsd.pjcloud.canteen.entity.CanteenHotelProductEntity; +import com.bsd.pjcloud.canteen.entity.CanteenHotelRoomEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "酒店详情(含上游拉取的房型/产品摘要)") +public class CanteenHotelDetailVO { + + private CanteenHotelEntity hotel; + + private List rooms = new ArrayList<>(); + + private List products = new ArrayList<>(); + + @Schema(description = "日历条数(未来含今天)") + private Long dailyCount; + + @Schema(description = "日历预览(默认最近一段,完整日历走分页接口)") + private List dailyPreview = new ArrayList<>(); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/CanteenSchedulingConfig.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/CanteenSchedulingConfig.java new file mode 100644 index 000000000..b2e4058f6 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/CanteenSchedulingConfig.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.canteen.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 启用 Spring @Scheduled(酒店列表日更 / 滚价可走 YAML Cron,不必依赖 XXL)。 + */ +@Configuration +@EnableScheduling +public class CanteenSchedulingConfig { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/HotelDownstreamProperties.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/HotelDownstreamProperties.java new file mode 100644 index 000000000..8ebc8e5dd --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/HotelDownstreamProperties.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.canteen.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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java new file mode 100644 index 000000000..38c6a5adf --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java @@ -0,0 +1,188 @@ +package com.bsd.pjcloud.canteen.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)。 + *

+ * 测试环境文档是两个地址(鉴权 /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 提前失效秒数(token 默认 21600) */ + 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; + + // ---------- 日更 / 差集(可配置)---------- + + /** 酒店列表日同步:是否允许定时/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; + + /** 文档:全量房价房量每天最多次数 */ + private int fullPriceSyncMaxPerDay = 3; + + /** 文档:全量房价两次间隔最少小时数 */ + private int fullPriceSyncMinIntervalHours = 6; + + /** 全量拉详情:每家酒店间隔毫秒(防打爆上游;落库已批量,默认 0) */ + private int detailPullIntervalMs = 0; + + /** 全量拉详情单次最多处理家数;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(与业务同前缀)。 + *

+ * 生产对方只给 {@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; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelCallbackTaskController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelCallbackTaskController.java new file mode 100644 index 000000000..340e6c675 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelCallbackTaskController.java @@ -0,0 +1,51 @@ +package com.bsd.pjcloud.canteen.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.canteen.entity.CanteenHotelCallbackTaskEntity; +import com.bsd.pjcloud.canteen.service.CanteenHotelCallbackTaskService; +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("/canteenHotelCallback") +@Tag(name = "酒店上游回调任务") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class CanteenHotelCallbackTaskController { + + private final CanteenHotelCallbackTaskService callbackTaskService; + + @Operation(summary = "回调任务分页") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotelCallback_view')") + public R> page(@ParameterObject Page page, + @ParameterObject CanteenHotelCallbackTaskEntity query) { + return R.ok(callbackTaskService.pageTasks(page, query)); + } + + @Operation(summary = "回调任务详情") + @GetMapping("/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotelCallback_view')") + public R detail(@PathVariable Long id) { + return R.ok(callbackTaskService.getDetail(id)); + } + + @SysLog("酒店回调任务重试") + @Operation(summary = "失败/跳过任务重新入队") + @PostMapping("/retry/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotelCallback_retry')") + public R retry(@PathVariable Long id) { + return callbackTaskService.retry(id); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java index 8b3688885..bc24da9e4 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java @@ -40,9 +40,33 @@ public class CanteenHotelController { .eq(StrUtil.isNotBlank(query.getSheetName()), CanteenHotelEntity::getSheetName, query.getSheetName()) .orderByDesc(CanteenHotelEntity::getCreateTime) .orderByDesc(CanteenHotelEntity::getId); + // 启用:含未写状态的老数据;停用:仅 OFFLINE;空=全部 + if ("OFFLINE".equalsIgnoreCase(query.getUpstreamStatus())) { + wrapper.eq(CanteenHotelEntity::getUpstreamStatus, "OFFLINE"); + } + else if ("ACTIVE".equalsIgnoreCase(query.getUpstreamStatus())) { + wrapper.and(w -> w.isNull(CanteenHotelEntity::getUpstreamStatus) + .or() + .ne(CanteenHotelEntity::getUpstreamStatus, "OFFLINE")); + } return R.ok(hotelService.pageScope(page, wrapper)); } + /** 上传文件会立刻 transferTo 临时目录,勿加 @SysLog */ + @Operation(summary = "提交Excel异步导入(多Sheet流式)") + @PostMapping("/import") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_import')") + public R importExcel(@RequestParam("file") MultipartFile file) { + return hotelService.startImport(file); + } + + @Operation(summary = "查询酒店导入进度") + @GetMapping("/import/status/{taskId}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_import')") + public R importStatus(@PathVariable String taskId) { + return hotelService.getImportStatus(taskId); + } + @Operation(summary = "详情") @GetMapping("/{id}") @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") @@ -73,19 +97,4 @@ public class CanteenHotelController { public R remove(@RequestBody Long[] ids) { return R.ok(hotelService.removeHotels(ids)); } - - /** 上传文件会立刻 transferTo 临时目录,勿加 @SysLog,否则会序列化已删除的 Undertow 临时文件报 NoSuchFileException */ - @Operation(summary = "提交Excel异步导入(多Sheet流式)") - @PostMapping("/import") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_import')") - public R importExcel(@RequestParam("file") MultipartFile file) { - return hotelService.startImport(file); - } - - @Operation(summary = "查询酒店导入进度") - @GetMapping("/import/status/{taskId}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_import')") - public R importStatus(@PathVariable String taskId) { - return hotelService.getImportStatus(taskId); - } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelDownstreamOrderController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelDownstreamOrderController.java new file mode 100644 index 000000000..6981886ca --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelDownstreamOrderController.java @@ -0,0 +1,68 @@ +package com.bsd.pjcloud.canteen.controller; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.canteen.integration.downstream.DownstreamInboundAuth; +import com.bsd.pjcloud.canteen.service.CanteenHotelOrderService; +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 CanteenHotelDownstreamOrderController { + + private final DownstreamInboundAuth inboundAuth; + + private final CanteenHotelOrderService orderService; + + @Inner(value = false) + @Operation(summary = "下游下单回传(我方再调上游创建)") + @PostMapping("/create") + public R 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 body = JSONUtil.parseObj(rawBody); + return orderService.createFromDownstream(appKey, body); + } + + @Inner(value = false) + @Operation(summary = "下游取消回传") + @PostMapping("/cancel") + public R 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 body = JSONUtil.parseObj(rawBody); + return orderService.cancelFromDownstream(appKey, body); + } + + @Inner(value = false) + @Operation(summary = "下游查单") + @PostMapping("/query") + public R 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 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); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java new file mode 100644 index 000000000..107ff819e --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java @@ -0,0 +1,197 @@ +package com.bsd.pjcloud.canteen.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.canteen.entity.CanteenHotelDailyEntity; +import com.bsd.pjcloud.canteen.entity.CanteenHotelPriceConfigEntity; +import com.bsd.pjcloud.canteen.entity.CanteenHotelSyncLogEntity; +import com.bsd.pjcloud.canteen.service.CanteenHotelMidService; +import com.bsd.pjcloud.canteen.service.CanteenHotelPriceConfigService; +import com.bsd.pjcloud.canteen.service.CanteenHotelSyncService; +import com.bsd.pjcloud.canteen.vo.CanteenHotelDetailVO; +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("/canteenHotel") +@Tag(name = "酒店中转(拉取/推送/加价)") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class CanteenHotelMidController { + + private final CanteenHotelMidService midService; + + private final CanteenHotelPriceConfigService priceConfigService; + + private final CanteenHotelSyncService syncService; + + @Operation(summary = "查询全局加价配置") + @GetMapping("/priceConfig") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotelPrice_view')") + public R getPriceConfig() { + return priceConfigService.getCurrent(); + } + + @SysLog("保存酒店加价配置") + @Operation(summary = "保存全局加价配置") + @PutMapping("/priceConfig") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotelPrice_edit')") + public R savePriceConfig(@RequestBody CanteenHotelPriceConfigEntity entity) { + return priceConfigService.saveOrUpdateConfig(entity); + } + + @Operation(summary = "酒店详情(含已拉取房型/产品/日历预览)") + @GetMapping("/detail/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R detail(@PathVariable Long id) { + return midService.getHotelDetail(id); + } + + @Operation(summary = "酒店日历分页") + @GetMapping("/detail/{id}/daily") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R> dailyPage(@PathVariable Long id, + @ParameterObject Page page, + @RequestParam(required = false) String productCode) { + return midService.pageDaily(id, page, productCode); + } + + @Operation(summary = "从上游拉取房型产品并加价落库") + @PostMapping("/mid/pull/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + public R> pull(@PathVariable Long id) { + return midService.pullFromUpstream(id); + } + + @Operation(summary = "推送到下游销售方") + @PostMapping("/mid/push/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_push')") + public R> push(@PathVariable Long id) { + return midService.pushToDownstream(id); + } + + @Operation(summary = "拉取上游并推送下游") + @PostMapping("/mid/pullAndPush/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_push')") + public R> pullAndPush(@PathVariable Long id) { + return midService.pullAndPush(id); + } + + @Operation(summary = "批量拉取并推送") + @PostMapping("/mid/pullAndPush/batch") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_push')") + public R> pullAndPushBatch(@RequestBody List ids) { + return midService.pullAndPushBatch(ids); + } + + @Operation(summary = "查询上游账号已授权酒店列表") + @GetMapping("/mid/authorizedHotels") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R> 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('canteen_canteenHotel_add')") + public R> importAuthorizedHotels(@RequestBody List> hotels) { + return midService.importAuthorizedHotels(hotels); + } + + @SysLog("同步上游酒店列表并差集") + @Operation(summary = "异步同步 getHotelList + Redis 差集(立刻返回 logId,轮询进度)") + @PostMapping("/mid/syncHotelList") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + public R> syncHotelList( + @RequestParam(required = false, defaultValue = "true") boolean autoImport) { + return syncService.syncHotelListAndDiff(autoImport); + } + + @Operation(summary = "同步日志分页") + @GetMapping("/mid/syncLog/page") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R> syncLogPage(@ParameterObject Page page, + @ParameterObject CanteenHotelSyncLogEntity query) { + return R.ok(syncService.pageSyncLogs(page, query)); + } + + @Operation(summary = "查询单次同步进度/结果") + @GetMapping("/mid/syncLog/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R syncLog(@PathVariable Long id) { + return syncService.getSyncLog(id); + } + + @Operation(summary = "日更时间配置(只读展示)") + @GetMapping("/mid/syncSchedule") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R> syncSchedule() { + return R.ok(syncService.getSyncSchedule()); + } + + @Operation(summary = "分页查看酒店差集 new|offline") + @GetMapping("/mid/hotelDiff") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R> 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('canteen_canteenHotel_add')") + public R> importDiffNew(@RequestParam(required = false) Integer limit) { + return syncService.importDiffNewHotels(limit); + } + + @SysLog("手动执行滚一天房价") + @Operation(summary = "异步滚一天房价(立刻返回 logId)") + @PostMapping("/mid/rollDailyPrice") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + public R> rollDailyPrice() { + R started = syncService.startPriceRollAsync("MANUAL"); + if (started.getCode() != 0) { + return R.failed(started.getMsg()); + } + Map 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('canteen_canteenHotel_edit')") + public R> pullAllActiveDetails( + @RequestParam(required = false) Integer limit) { + R started = syncService.startDetailPullAsync(limit, "MANUAL"); + if (started.getCode() != 0) { + return R.failed(started.getMsg()); + } + Map 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()); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelOrderController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelOrderController.java new file mode 100644 index 000000000..6edbcaee5 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelOrderController.java @@ -0,0 +1,67 @@ +package com.bsd.pjcloud.canteen.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.canteen.entity.CanteenHotelOrderEntity; +import com.bsd.pjcloud.canteen.service.CanteenHotelOrderService; +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("/canteenHotelOrder") +@Tag(name = "酒店中转订单") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class CanteenHotelOrderController { + + private final CanteenHotelOrderService orderService; + + @Operation(summary = "订单分页") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotelOrder_view')") + public R> page(@ParameterObject Page page, + @ParameterObject CanteenHotelOrderEntity query) { + return R.ok(orderService.pageOrders(page, query)); + } + + @Operation(summary = "订单详情") + @GetMapping("/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotelOrder_view')") + public R detail(@PathVariable Long id) { + return R.ok(orderService.getDetail(id)); + } + + @SysLog("后台取消酒店订单") + @Operation(summary = "后台取消订单") + @PostMapping("/cancel/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotelOrder_cancel')") + public R cancel(@PathVariable Long id, @RequestBody(required = false) Map 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('canteen_canteenHotel_edit')") + public R createByAdmin(@RequestBody Map body) { + return orderService.createByAdmin(body); + } + + @SysLog("酒店订单补偿查询") + @Operation(summary = "立即补偿查询(queryByRequestNo)") + @PostMapping("/compensate/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotelOrder_view')") + public R compensate(@PathVariable Long id) { + return orderService.compensateNow(id); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelUpstreamController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelUpstreamController.java new file mode 100644 index 000000000..880c9f332 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelUpstreamController.java @@ -0,0 +1,134 @@ +package com.bsd.pjcloud.canteen.controller; + +import cn.hutool.json.JSONObject; +import com.bsd.pjcloud.canteen.service.CanteenHotelUpstreamService; +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("/canteenHotel/upstream") +@Tag(name = "酒店上游对接") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class CanteenHotelUpstreamController { + + private final CanteenHotelUpstreamService upstreamService; + + @Operation(summary = "同步上游酒店信息到本地") + @PostMapping("/sync/{id}") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + public R sync(@PathVariable Long id) { + return upstreamService.syncHotelInfo(id); + } + + @Operation(summary = "批量同步上游酒店信息") + @PostMapping("/sync/batch") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + public R> syncBatch(@RequestBody List ids) { + return upstreamService.syncHotelInfoBatch(ids); + } + + @Operation(summary = "上游-酒店综合信息") + @PostMapping("/hotelInfo") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R hotelInfo(@RequestBody Map body) { + return upstreamService.hotelInfo(str(body.get("groupCode")), str(body.get("hotelCode"))); + } + + @Operation(summary = "上游-房型列表") + @PostMapping("/roomTypeList") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R roomTypeList(@RequestBody Map body) { + return upstreamService.roomTypeList(str(body.get("groupCode")), str(body.get("hotelCode"))); + } + + @Operation(summary = "上游-房价房量样例") + @PostMapping("/productList") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R productList(@RequestBody Map body) { + return upstreamService.productList(str(body.get("groupCode")), str(body.get("hotelCode")), + str(body.get("channelCode"))); + } + + @Operation(summary = "上游-可定查询") + @PostMapping("/order/check") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R orderCheck(@RequestBody Map body) { + return upstreamService.orderCheck(body); + } + + @Operation(summary = "上游-下单") + @PostMapping("/order/create") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + public R orderCreate(@RequestBody Map body) { + return upstreamService.orderCreate(body); + } + + @Operation(summary = "上游-取消订单") + @PostMapping("/order/cancel") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + public R orderCancel(@RequestBody Map body) { + return upstreamService.orderCancel(body); + } + + @Operation(summary = "上游-订单详情") + @PostMapping("/order/getById") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + public R orderGetById(@RequestBody Map 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 priceNotify(@RequestBody Map 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 inventoryNotify(@RequestBody Map 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 orderStatusNotify(@RequestBody Map 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); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelApi.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelApi.java new file mode 100644 index 000000000..4603779f7 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelApi.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.canteen.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"; +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelClient.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelClient.java new file mode 100644 index 000000000..f1b1bdef8 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelClient.java @@ -0,0 +1,97 @@ +package com.bsd.pjcloud.canteen.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.canteen.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; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamInboundAuth.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamInboundAuth.java new file mode 100644 index 000000000..a39cee90a --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamInboundAuth.java @@ -0,0 +1,60 @@ +package com.bsd.pjcloud.canteen.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.canteen.entity.CanteenHotelDownstreamAppEntity; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelDownstreamAppMapper; +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 CanteenHotelDownstreamAppMapper appMapper; + + private final StringRedisTemplate redisTemplate; + + public CanteenHotelDownstreamAppEntity 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 重复"); + } + CanteenHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelDownstreamAppEntity::getAppKey, appKey) + .eq(CanteenHotelDownstreamAppEntity::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; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamSignHelper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamSignHelper.java new file mode 100644 index 000000000..f8e4fbbc1 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamSignHelper.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.canteen.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=×tamp=&appSecret=).toUpperCase() + */ +public final class DownstreamSignHelper { + + private DownstreamSignHelper() { + } + + public static String sign(String appKey, String appSecret, String timestamp, String nonce, String bodyJson) { + Map 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(); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouBookStatus.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouBookStatus.java new file mode 100644 index 000000000..f4bcf3267 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouBookStatus.java @@ -0,0 +1,52 @@ +package com.bsd.pjcloud.canteen.integration.shangyou; + +import cn.hutool.core.util.StrUtil; + +/** + * 上游 bookStatus / 本地 orderStatus 映射(订单相关接口模式.pdf 枚举)。 + *

    + *
  • P = 处理中
  • + *
  • R = 预订成功
  • + *
  • E = 入住/离店/NoShow(pmsAdapter I/O/N 映射为 bookStatus E)
  • + *
  • X = 已取消
  • + *
+ */ +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)); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java new file mode 100644 index 000000000..bdfaeec3e --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java @@ -0,0 +1,94 @@ +package com.bsd.pjcloud.canteen.integration.shangyou; + +import com.bsd.pjcloud.canteen.config.ShangyouHotelProperties; +import com.bsd.pjcloud.canteen.service.CanteenHotelCallbackTaskService; +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.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 消费 Redis 回调队列:BRPOP → 异步 pullFromUpstream。 + * 启动时把库里滞留的 PENDING 重新入队,避免重启丢任务。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ShangyouCallbackQueueWorker implements ApplicationRunner { + + private final ShangyouHotelProperties properties; + + private final StringRedisTemplate redisTemplate; + + private final CanteenHotelCallbackTaskService callbackTaskService; + + private final AtomicBoolean running = new AtomicBoolean(true); + + private Thread worker; + + @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(); + log.info("|回调队列|Worker 已启动|key={}|", ShangyouHotelApi.REDIS_CALLBACK_QUEUE); + } + + private void loop() { + while (running.get()) { + try { + String taskId = redisTemplate.opsForList() + .rightPop(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, 5, TimeUnit.SECONDS); + if (taskId == null || taskId.isBlank()) { + continue; + } + Long id; + try { + id = Long.valueOf(taskId.trim()); + } + catch (NumberFormatException e) { + log.warn("|回调队列|非法 taskId={}|", taskId); + continue; + } + callbackTaskService.processQueuedTask(id); + } + catch (Exception e) { + if (!running.get()) { + break; + } + log.error("|回调队列|消费异常", e); + try { + Thread.sleep(2000L); + } + catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + + @PreDestroy + public void shutdown() { + running.set(false); + if (worker != null) { + worker.interrupt(); + } + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackSignHelper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackSignHelper.java new file mode 100644 index 000000000..ad6023407 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackSignHelper.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.canteen.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); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java new file mode 100644 index 000000000..739e85192 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java @@ -0,0 +1,60 @@ +package com.bsd.pjcloud.canteen.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"; + + public static final String REDIS_TOKEN_KEY = "canteen:shangyou:hotel:token"; + + public static final String REDIS_TOKEN_LOCK = "canteen:shangyou:hotel:token:lock"; + + /** 授权酒店 HASH:field=hotelId value=hotelName(适配十几万量级) */ + public static final String REDIS_HOTEL_HASH_KEY = "canteen:shangyou:hotel:hash:v4"; + + /** 授权酒店 ZSET:score=序号 member=hotelId,深分页 O(logN) */ + public static final String REDIS_HOTEL_ZSET_KEY = "canteen:shangyou:hotel:zset:v4"; + + /** 酒店列表同步锁 */ + public static final String REDIS_HOTEL_LIST_LOCK = "canteen:shangyou:hotel:list:lock"; + + /** 房价/房量回调异步队列(LIST,元素=taskId) */ + public static final String REDIS_CALLBACK_QUEUE = "canteen:shangyou:hotel:callback:queue"; + + /** 上游授权 hotelId 集合(与 HASH/ZSET 同步重建,供 SDIFF) */ + public static final String REDIS_UPSTREAM_ID_SET = "canteen:shangyou:hotel:idset:v4"; + + /** 本地 hotel_code 集合(分批从 DB 重建) */ + public static final String REDIS_LOCAL_CODE_SET = "canteen:shangyou:hotel:local:codes:v1"; + + /** 差集:上游有本地无(待导入) */ + public static final String REDIS_DIFF_NEW_SET = "canteen:shangyou:hotel:diff:new:v1"; + + /** 差集:本地有上游无(疑似下线) */ + public static final String REDIS_DIFF_OFFLINE_SET = "canteen:shangyou:hotel:diff:offline:v1"; + + /** 今日全量房价同步次数 */ + public static final String REDIS_FULL_PRICE_SYNC_COUNT = "canteen:shangyou:hotel:fullprice:count"; + + /** 上次全量房价同步时间 epochMs */ + public static final String REDIS_FULL_PRICE_SYNC_LAST = "canteen:shangyou:hotel:fullprice:last"; +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java new file mode 100644 index 000000000..4d2c45588 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java @@ -0,0 +1,703 @@ +package com.bsd.pjcloud.canteen.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.canteen.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.Cursor; +import org.springframework.data.redis.core.DefaultTypedTuple; +import org.springframework.data.redis.core.ScanOptions; +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); + } + + public String getAccessToken(boolean forceRefresh) { + ensureEnabled(); + if (!forceRefresh) { + String cached = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_TOKEN_KEY); + if (StrUtil.isNotBlank(cached)) { + return cached; + } + } + else { + redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_KEY); + } + Boolean locked = redisTemplate.opsForValue() + .setIfAbsent(ShangyouHotelApi.REDIS_TOKEN_LOCK, "1", 30, TimeUnit.SECONDS); + try { + if (!forceRefresh) { + String cached = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_TOKEN_KEY); + if (StrUtil.isNotBlank(cached)) { + return cached; + } + } + return fetchAndCacheToken(); + } + finally { + if (Boolean.TRUE.equals(locked)) { + redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_LOCK); + } + } + } + + 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; + } + long ttl = Math.max(60L, expires - properties.getTokenSkewSeconds()); + redisTemplate.opsForValue().set(ShangyouHotelApi.REDIS_TOKEN_KEY, token, ttl, TimeUnit.SECONDS); + 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; + } + + /** + * 分页查询授权酒店。 + *
    + *
  • 无关键字:ZRANGE + HMGET,深分页也稳定
  • + *
  • 精确 hotelId:HGET O(1)
  • + *
  • 模糊关键字:HSCAN 分批,只保留当前页(十几万时比全量装载安全,但首次统计全量匹配会稍慢)
  • + *
+ */ + 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 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 page = new ArrayList<>((int) size); + ScanOptions options = ScanOptions.scanOptions().count(1000).build(); + try (Cursor> cursor = redisTemplate.opsForHash() + .scan(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY, options)) { + while (cursor.hasNext()) { + Map.Entry e = cursor.next(); + String hotelId = String.valueOf(e.getKey()); + String hotelName = e.getValue() == null ? "" : String.valueOf(e.getValue()); + if (!hotelId.toLowerCase().contains(kwLower) && !hotelName.toLowerCase().contains(kwLower)) { + continue; + } + if (matched >= skip && page.size() < need) { + page.add(new HotelBrief(hotelId, hotelName)); + } + 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("canteen:shangyou:hotel:list:v3"); + redisTemplate.delete("canteen:shangyou:hotel:list:v2"); + } + + private List loadBriefsByIds(Set ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + List idList = new ArrayList<>(ids.size()); + idList.addAll(ids); + List names = redisTemplate.opsForHash().multiGet(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY, idList); + List 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 hashBatch = new HashMap<>(512); + Set> zsetBatch = new HashSet<>(512); + Set 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 records, long total) { + } + + /** + * 酒店综合信息(静态+房型)。返回完整上游响应(含 code/message/content)。 + */ + public JSONObject getHotelComplexListRaw(Collection hotelIds) { + JSONObject body = baseCardBody(); + body.put("hotelIdList", hotelIds); + return postBiz(ShangyouHotelApi.HOTEL_COMPLEX, body); + } + + public JSONArray getHotelComplexList(Collection hotelIds) { + return asArray(getHotelComplexListRaw(hotelIds).get("content")); + } + + /** + * 房价房量。返回完整上游响应(含 code/message/content)。 + */ + public JSONObject getRoomPriceRaw(String hotelId, String checkInDate, String checkOutDate, + List 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 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); + } + + public JSONObject getRoomPrice(String hotelId, String checkInDate, String checkOutDate, + List 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); + } + + private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body, boolean retried) { + ensureEnabled(); + String token; + try { + token = getAccessToken(retried); + } + 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)) { + redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_KEY); + if (!retried) { + return postOrderBiz(path, body, true); + } + 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)) { + redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_KEY); + if (!retried) { + return postOrderBiz(path, body, true); + } + 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 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, false); + } + + /** + * 业务调用。文档约定业务码 504 = token 失效,需重新取 token 后再调一次。 + */ + private JSONObject postBiz(String path, JSONObject body, boolean retried) { + ensureEnabled(); + String token = getAccessToken(retried); + String url = properties.resolveBizBaseUrl() + path; + log.info("shangyou 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(); + if (response.getStatus() == 401 || isTokenExpiredBiz(respBody)) { + redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_KEY); + if (!retried) { + log.warn("shangyou token expired, refresh and retry path={}", path); + return postBiz(path, body, true); + } + 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)) { + redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_KEY); + if (!retried) { + log.warn("shangyou biz code token invalid, refresh and retry path={}", path); + return postBiz(path, body, true); + } + throw new CheckedException("上游 Token 失效且重试仍失败 path=" + path + " body=" + respBody); + } + 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 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; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCallResult.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCallResult.java new file mode 100644 index 000000000..0c952eb10 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCallResult.java @@ -0,0 +1,80 @@ +package com.bsd.pjcloud.canteen.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; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCompensateWorker.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCompensateWorker.java new file mode 100644 index 000000000..14356bc26 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCompensateWorker.java @@ -0,0 +1,80 @@ +package com.bsd.pjcloud.canteen.integration.shangyou; + +import com.bsd.pjcloud.canteen.config.ShangyouHotelProperties; +import com.bsd.pjcloud.canteen.service.CanteenHotelOrderService; +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 CanteenHotelOrderService 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(); + } + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSpringSchedule.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSpringSchedule.java new file mode 100644 index 000000000..4207a08c5 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSpringSchedule.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.canteen.job; + +import com.bsd.pjcloud.canteen.config.ShangyouHotelProperties; +import com.bsd.pjcloud.canteen.service.CanteenHotelSyncService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * YAML 可配 Cron 的日更入口(与 XXL Job 二选一即可)。 + *

+ * 开关:{@code shangyou-hotel.spring-schedule-enabled}
+ * 时间:{@code hotel-list-daily-cron} / {@code price-roll-daily-cron} + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CanteenHotelShangyouSpringSchedule { + + private final ShangyouHotelProperties properties; + + private final CanteenHotelSyncService 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); + } + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSyncJob.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSyncJob.java new file mode 100644 index 000000000..ad6b82638 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSyncJob.java @@ -0,0 +1,56 @@ +package com.bsd.pjcloud.canteen.job; + +import com.bsd.pjcloud.canteen.service.CanteenHotelSyncService; +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; + +/** + * 上游酒店日更: + *

    + *
  • {@link #HANDLER_LIST_SYNC}:getHotelList + Redis 差集 + 可选自动导入新增
  • + *
  • {@link #HANDLER_PRICE_ROLL}:对本地酒店只拉 today+offset 一天房价
  • + *
+ * 频率在 XXL-JOB 控制台配 Cron;开关与参数在 shangyou-hotel.*。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CanteenHotelShangyouSyncJob { + + public static final String HANDLER_LIST_SYNC = "canteenHotelListSyncJobHandler"; + + public static final String HANDLER_PRICE_ROLL = "canteenHotelPriceRollJobHandler"; + + private final CanteenHotelSyncService syncService; + + @XxlJob(HANDLER_LIST_SYNC) + public void canteenHotelListSyncJobHandler() { + try { + Map r = syncService.jobSyncHotelList(); + XxlJobHelper.log("hotel list sync: " + r); + XxlJobHelper.handleSuccess(String.valueOf(r)); + } + catch (Exception e) { + log.error("canteenHotelListSyncJobHandler 异常", e); + XxlJobHelper.handleFail(e.getMessage()); + } + } + + @XxlJob(HANDLER_PRICE_ROLL) + public void canteenHotelPriceRollJobHandler() { + try { + Map r = syncService.jobRollDailyPrice(); + XxlJobHelper.log("hotel price roll: " + r); + XxlJobHelper.handleSuccess(String.valueOf(r)); + } + catch (Exception e) { + log.error("canteenHotelPriceRollJobHandler 异常", e); + XxlJobHelper.handleFail(e.getMessage()); + } + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelCallbackTaskMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelCallbackTaskMapper.java new file mode 100644 index 000000000..3c6f0ad03 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelCallbackTaskMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelCallbackTaskEntity; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CanteenHotelCallbackTaskMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java new file mode 100644 index 000000000..70ef041fb --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java @@ -0,0 +1,22 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelDailyEntity; +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 java.time.LocalDate; +import java.util.List; + +@Mapper +public interface CanteenHotelDailyMapper extends PjcloudBaseMapper { + + /** + * 物理删除未来日历。唯一键 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 list); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDownstreamAppMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDownstreamAppMapper.java new file mode 100644 index 000000000..dcf0d1ed3 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDownstreamAppMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelDownstreamAppEntity; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CanteenHotelDownstreamAppMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelOrderMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelOrderMapper.java new file mode 100644 index 000000000..0f2a4ae30 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelOrderMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelOrderEntity; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CanteenHotelOrderMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelPriceConfigMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelPriceConfigMapper.java new file mode 100644 index 000000000..6fd9c404e --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelPriceConfigMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelPriceConfigEntity; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CanteenHotelPriceConfigMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelProductMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelProductMapper.java new file mode 100644 index 000000000..3324bf672 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelProductMapper.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelProductEntity; +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 CanteenHotelProductMapper extends PjcloudBaseMapper { + + int insertBatch(@Param("list") List list); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelRoomMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelRoomMapper.java new file mode 100644 index 000000000..50763c225 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelRoomMapper.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelRoomEntity; +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 CanteenHotelRoomMapper extends PjcloudBaseMapper { + + int insertBatch(@Param("list") List list); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelSyncLogMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelSyncLogMapper.java new file mode 100644 index 000000000..090be7088 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelSyncLogMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.canteen.mapper; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelSyncLogEntity; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CanteenHotelSyncLogMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelCallbackTaskService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelCallbackTaskService.java new file mode 100644 index 000000000..f090b9d1e --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelCallbackTaskService.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.canteen.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.canteen.entity.CanteenHotelCallbackTaskEntity; +import com.bsd.pjcloud.common.core.util.R; + +import java.util.Map; + +/** + * 上游房价/房量回调:落库入队 + 异步拉取 + 后台查询。 + */ +public interface CanteenHotelCallbackTaskService { + + /** + * 验签通过后调用:只落库 + 入 Redis 队列,不做上游拉取。 + * @return Shangyou 格式 {code,message} + */ + Map enqueueFromNotify(String notifyType, Map body, String time, String sign); + + /** 后台分页 */ + Page pageTasks(Page page, + CanteenHotelCallbackTaskEntity query); + + CanteenHotelCallbackTaskEntity getDetail(Long id); + + /** 失败/跳过任务重新入队 */ + R retry(Long id); + + /** 消费一条队列任务(Worker 调用) */ + void processQueuedTask(Long taskId); + + /** 启动时把滞留 PENDING 重新入队 */ + int requeueStalePending(); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelMidService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelMidService.java new file mode 100644 index 000000000..012127036 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelMidService.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.canteen.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.canteen.entity.CanteenHotelDailyEntity; +import com.bsd.pjcloud.canteen.vo.CanteenHotelDetailVO; +import com.bsd.pjcloud.common.core.util.R; + +import java.util.List; +import java.util.Map; + +/** + * 上游拉取落库 + 加价后推下游。 + */ +public interface CanteenHotelMidService { + + /** 从上游拉取房型/产品并按加价生成日历,落本地库 */ + R> pullFromUpstream(Long hotelId); + + /** + * 仅拉取指定日期区间房价(含房型综合若本地无房型),用于「滚一天」;区间建议 1 天,最长按上游 31 天切片。 + */ + R> pullPriceRange(Long hotelId, java.time.LocalDate startInclusive, + java.time.LocalDate endExclusive); + + /** 将本地酒店+房型+产品+日历推送给下游销售方 */ + R> pushToDownstream(Long hotelId); + + R> pullAndPush(Long hotelId); + + R> pullAndPushBatch(List hotelIds); + + /** 酒店详情:主数据 + 已拉取的房型/产品/日历预览 */ + R getHotelDetail(Long hotelId); + + /** 日历分页 */ + R> pageDaily(Long hotelId, Page page, String productCode); + + /** 查询当前上游账号已授权酒店列表(getHotelList,支持关键字与分页) */ + R> listAuthorizedHotels(String keyword, long current, long size, boolean refresh); + + /** 重建本地 hotel_code Redis SET(分批,防 OOM);供差集与列表标记复用 */ + long rebuildLocalHotelCodeSet(); + + /** 将选中的上游 hotelId 写入本地(不存在则新增) */ + R> importAuthorizedHotels(List> hotels); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelOrderService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelOrderService.java new file mode 100644 index 000000000..4fcb66965 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelOrderService.java @@ -0,0 +1,43 @@ +package com.bsd.pjcloud.canteen.service; + +import cn.hutool.json.JSONObject; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.canteen.entity.CanteenHotelOrderEntity; +import com.bsd.pjcloud.common.core.util.R; + +import java.util.Map; + +public interface CanteenHotelOrderService { + + Page pageOrders(Page page, CanteenHotelOrderEntity query); + + CanteenHotelOrderEntity getDetail(Long id); + + /** 下游回传下单 → 可定校验 → 上游创建 → 必要时挂补偿 */ + R createFromDownstream(String appKey, Map body); + + /** + * PC 后台试下单(不依赖下游 appKey,channelNo 可自动生成)。 + * 必填:hotelId、productCode、checkInDate、checkOutDate、contactName、contactPhone + */ + R createByAdmin(Map body); + + /** 下游回传取消 → 调上游取消 */ + R cancelFromDownstream(String appKey, Map body); + + R queryFromDownstream(String appKey, Map body); + + /** PC 后台取消 */ + R cancelByAdmin(Long id, String reason); + + /** Worker:扫描到期补偿任务 */ + int processDueCompensate(int limit); + + /** PC 手工立刻补偿查询 */ + R compensateNow(Long id); + + /** + * 预订单状态变更回调落库(验签后由上层调用)。 + */ + Map handleOrderStatusNotify(Map body); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelPriceConfigService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelPriceConfigService.java new file mode 100644 index 000000000..0c9f57797 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelPriceConfigService.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.canteen.service; + +import com.bsd.pjcloud.canteen.entity.CanteenHotelPriceConfigEntity; +import com.bsd.pjcloud.common.core.util.R; + +import java.math.BigDecimal; + +public interface CanteenHotelPriceConfigService { + + R getCurrent(); + + R saveOrUpdateConfig(CanteenHotelPriceConfigEntity entity); + + /** 当前加价百分比,未配置返回 0 */ + BigDecimal currentMarkupPercent(); + + /** 底价按加价百分比计算卖价 */ + BigDecimal applyMarkup(BigDecimal costPrice); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelSyncService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelSyncService.java new file mode 100644 index 000000000..d489ba0ac --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelSyncService.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.canteen.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.canteen.entity.CanteenHotelSyncLogEntity; +import com.bsd.pjcloud.common.core.util.R; + +import java.util.Map; + +/** + * 上游酒店列表日更、Redis 差集、滚一天房价(异步 + 进度日志)。 + */ +public interface CanteenHotelSyncService { + + /** + * 提交列表同步(立刻返回 logId,后台异步执行)。 + * @param autoImport 是否自动导入新增 + * @param triggerType MANUAL / XXL / CRON + */ + R startListSyncAsync(boolean autoImport, String triggerType); + + /** 查询同步任务进度/结果 */ + R getSyncLog(Long id); + + /** 同步日志分页 */ + Page pageSyncLogs(Page page, CanteenHotelSyncLogEntity query); + + /** 分页查看差集:type=new|offline */ + R> pageHotelDiff(String type, long current, long size); + + /** 把差集「新增」全部/限额导入本地主数据 */ + R> importDiffNewHotels(Integer limit); + + /** 提交滚价异步任务 */ + R startPriceRollAsync(String triggerType); + + /** + * 异步拉取全部启用酒店详情(房型+产品+未来 price-days 房价)。 + * @param limit 最多处理家数,null/≤0 用配置 detail-pull-max-hotels(0=不限) + */ + R startDetailPullAsync(Integer limit, String triggerType); + + /** 兼容旧同步入口(内部转异步) */ + R> syncHotelListAndDiff(boolean autoImport); + + /** 当前日更时间配置(给前端展示) */ + Map getSyncSchedule(); + + /** XXL / 定时:列表日更 */ + Map jobSyncHotelList(); + + Map jobSyncHotelList(String triggerType); + + /** XXL / 定时:滚价 */ + Map jobRollDailyPrice(); + + Map jobRollDailyPrice(String triggerType); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelUpstreamService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelUpstreamService.java new file mode 100644 index 000000000..4578bcc0d --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelUpstreamService.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.canteen.service; + +import cn.hutool.json.JSONObject; +import com.bsd.pjcloud.common.core.util.R; + +import java.util.List; +import java.util.Map; + +/** + * 玉编电云酒店上游对接(查询/同步/下单代理)。 + */ +public interface CanteenHotelUpstreamService { + + /** 按本地酒店主键拉取上游综合信息并回写名称等 */ + R syncHotelInfo(Long hotelId); + + /** 批量同步 */ + R> syncHotelInfoBatch(List hotelIds); + + R hotelInfo(String groupCode, String hotelCode); + + R roomTypeList(String groupCode, String hotelCode); + + R productList(String groupCode, String hotelCode, String channelCode); + + R orderCreate(Map body); + + R orderCancel(Map body); + + R orderGetById(Map body); + + R orderCheck(Map body); + + /** 接收上游变更通知 */ + R handleNotify(Map body); + + /** 接收上游变更通知(Header: time / sign=MD5(Secret+time)) */ + R handleNotify(Map body, String time, String sign); + + /** + * 玉编电云房价/房量回调:验签后落库入队,立即返回 {code,message};异步再拉取。 + * @param notifyType PRICE / INVENTORY + */ + Map handleShangyouNotify(String notifyType, Map body, String time, String sign); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelCallbackTaskServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelCallbackTaskServiceImpl.java new file mode 100644 index 000000000..4282cd7eb --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelCallbackTaskServiceImpl.java @@ -0,0 +1,310 @@ +package com.bsd.pjcloud.canteen.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.canteen.config.ShangyouHotelProperties; +import com.bsd.pjcloud.canteen.entity.CanteenHotelCallbackTaskEntity; +import com.bsd.pjcloud.canteen.entity.CanteenHotelEntity; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouCallbackSignHelper; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouHotelApi; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouHotelClient; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelCallbackTaskMapper; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelMapper; +import com.bsd.pjcloud.canteen.service.CanteenHotelCallbackTaskService; +import com.bsd.pjcloud.canteen.service.CanteenHotelMidService; +import com.bsd.pjcloud.canteen.service.CanteenHotelOrderService; +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.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 回调只验签落库入队;真正拉取在 Worker 异步执行。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CanteenHotelCallbackTaskServiceImpl implements CanteenHotelCallbackTaskService { + + 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 CanteenHotelCallbackTaskMapper taskMapper; + + private final CanteenHotelMapper hotelMapper; + + private final CanteenHotelMidService midService; + + private final CanteenHotelOrderService orderService; + + private final StringRedisTemplate redisTemplate; + + @Override + public Map enqueueFromNotify(String notifyType, Map body, String time, + String sign) { + Map 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 -> { + CanteenHotelCallbackTaskEntity task = buildTask(notifyType, body, time, tid); + taskMapper.insert(task); + if (properties.isCallbackQueueEnabled()) { + redisTemplate.opsForList().leftPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, + String.valueOf(task.getId())); + } + log.info("|上游酒店通知|已入队|taskId={}|hotelCode={}|hotelId={}|status={}|", task.getId(), + task.getHotelCode(), task.getHotelId(), task.getStatus()); + }); + } + catch (Exception e) { + log.error("|上游酒店通知|入队失败|type={}|", notifyType, e); + // 仍返回 200,避免对方因落库抖动反复重推;失败日志可人工补救 + ok.put("message", "成功!"); + } + return ok; + } + + @Override + public Page pageTasks(Page page, + CanteenHotelCallbackTaskEntity query) { + return taskMapper.selectPage(page, Wrappers.lambdaQuery() + .eq(StrUtil.isNotBlank(query.getNotifyType()), CanteenHotelCallbackTaskEntity::getNotifyType, + query.getNotifyType()) + .eq(StrUtil.isNotBlank(query.getStatus()), CanteenHotelCallbackTaskEntity::getStatus, query.getStatus()) + .like(StrUtil.isNotBlank(query.getHotelCode()), CanteenHotelCallbackTaskEntity::getHotelCode, + query.getHotelCode()) + .like(StrUtil.isNotBlank(query.getHotelName()), CanteenHotelCallbackTaskEntity::getHotelName, + query.getHotelName()) + .orderByDesc(CanteenHotelCallbackTaskEntity::getCreateTime)); + } + + @Override + public CanteenHotelCallbackTaskEntity getDetail(Long id) { + return taskMapper.selectById(id); + } + + @Override + public R retry(Long id) { + CanteenHotelCallbackTaskEntity 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())) { + CanteenHotelEntity hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelEntity::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); + redisTemplate.opsForList().leftPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, String.valueOf(task.getId())); + return R.ok(Boolean.TRUE, "已重新入队"); + } + + @Override + public void processQueuedTask(Long taskId) { + if (taskId == null) { + return; + } + Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + TenantBroker.runAs(tenantId, tid -> doProcess(taskId)); + } + + @Override + public int requeueStalePending() { + Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + return TenantBroker.applyAs(tenantId, tid -> { + var list = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelCallbackTaskEntity::getStatus, STATUS_PENDING) + .orderByAsc(CanteenHotelCallbackTaskEntity::getCreateTime) + .last("limit 200")); + int n = 0; + for (CanteenHotelCallbackTaskEntity t : list) { + redisTemplate.opsForList().leftPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, String.valueOf(t.getId())); + n++; + } + if (n > 0) { + log.info("|回调队列|重启恢复 PENDING 入队|count={}|", n); + } + return n; + }); + } + + private void doProcess(Long taskId) { + CanteenHotelCallbackTaskEntity task = taskMapper.selectById(taskId); + if (task == null) { + log.warn("|回调队列|任务不存在|taskId={}|", taskId); + return; + } + if (!STATUS_PENDING.equals(task.getStatus())) { + log.info("|回调队列|跳过非 PENDING|taskId={}|status={}|", taskId, task.getStatus()); + return; + } + 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.setUpdateTime(LocalDateTime.now()); + taskMapper.updateById(task); + + long t0 = System.currentTimeMillis(); + try { + if ("ORDER_STATUS".equalsIgnoreCase(task.getNotifyType())) { + Map body = StrUtil.isBlank(task.getReqJson()) ? Map.of() + : JSONUtil.parseObj(task.getReqJson()); + Map result = orderService.handleOrderStatusNotify(body); + finish(task, STATUS_SUCCESS, "订单状态更新: " + JSONUtil.toJsonStr(result), t0); + return; + } + if (task.getHotelId() == null && StrUtil.isNotBlank(task.getHotelCode())) { + CanteenHotelEntity hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelEntity::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; + } + R> pull = midService.pullFromUpstream(task.getHotelId()); + if (pull != null && pull.getCode() == 0) { + Object data = pull.getData(); + finish(task, STATUS_SUCCESS, + "拉取成功" + (data == null ? "" : ": " + StrUtil.maxLength(JSONUtil.toJsonStr(data), 800)), t0); + } + else { + String msg = pull == null ? "拉取返回空" : StrUtil.blankToDefault(pull.getMsg(), "拉取失败"); + handleFailOrRetry(task, msg, t0, max); + } + } + catch (Exception e) { + log.error("|回调队列|处理异常|taskId={}|", taskId, e); + handleFailOrRetry(task, StrUtil.maxLength(e.getMessage(), 800), t0, max); + } + } + + private void handleFailOrRetry(CanteenHotelCallbackTaskEntity 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); + redisTemplate.opsForList().leftPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, String.valueOf(task.getId())); + return; + } + finish(task, STATUS_FAILED, msg, t0); + } + + private void finish(CanteenHotelCallbackTaskEntity 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 CanteenHotelCallbackTaskEntity buildTask(String notifyType, Map body, String time, + Long tenantId) { + String hotelCode = str(body, "hotelId"); + CanteenHotelEntity hotel = null; + if (StrUtil.isNotBlank(hotelCode)) { + hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelEntity::getHotelCode, hotelCode) + .last("limit 1")); + } + CanteenHotelCallbackTaskEntity task = new CanteenHotelCallbackTaskEntity(); + 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("ORDER_STATUS".equalsIgnoreCase(notifyType) ? "已入队,等待异步更新订单状态" + : "已入队,等待异步拉取"); + task.setCreateBy("shangyou-callback"); + task.setCreateTime(LocalDateTime.now()); + task.setUpdateTime(LocalDateTime.now()); + task.setDelFlag("0"); + return task; + } + + private static String str(Map 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 successResp() { + Map ok = new LinkedHashMap<>(4); + ok.put("code", "200"); + ok.put("message", "成功!"); + return ok; + } + + private static Map failResp(String code, String message) { + Map ok = new LinkedHashMap<>(4); + ok.put("code", code); + ok.put("message", message); + return ok; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelMidServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelMidServiceImpl.java new file mode 100644 index 000000000..a6fd5dc65 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelMidServiceImpl.java @@ -0,0 +1,1023 @@ +package com.bsd.pjcloud.canteen.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +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.toolkit.Db; +import com.bsd.pjcloud.canteen.config.HotelDownstreamProperties; +import com.bsd.pjcloud.canteen.config.ShangyouHotelProperties; +import com.bsd.pjcloud.canteen.entity.*; +import com.bsd.pjcloud.canteen.integration.downstream.DownstreamHotelClient; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouHotelApi; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouHotelClient; +import com.bsd.pjcloud.canteen.mapper.*; +import com.bsd.pjcloud.canteen.service.CanteenHotelMidService; +import com.bsd.pjcloud.canteen.service.CanteenHotelPriceConfigService; +import com.bsd.pjcloud.canteen.vo.CanteenHotelDetailVO; +import com.bsd.pjcloud.common.core.constant.CommonConstants; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +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.*; + +/** + * 中转核心:上游(玉编电云)拉取 → 加价落库 → 推下游。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CanteenHotelMidServiceImpl implements CanteenHotelMidService { + + /** 多值 INSERT / JDBC 批更新分片大小 */ + private static final int PERSIST_BATCH = 300; + + /** 上游单次房价查询建议不超过 31 天 */ + private static final int ROOM_PRICE_CHUNK_DAYS = 30; + + private final ShangyouHotelClient upstreamClient; + + private final ShangyouHotelProperties upstreamProperties; + + private final DownstreamHotelClient downstreamClient; + + private final HotelDownstreamProperties downstreamProperties; + + private final CanteenHotelPriceConfigService priceConfigService; + + private final CanteenHotelMapper hotelMapper; + + private final CanteenHotelRoomMapper roomMapper; + + private final CanteenHotelProductMapper productMapper; + + private final CanteenHotelDailyMapper dailyMapper; + + private final StringRedisTemplate stringRedisTemplate; + + @Override + @Transactional(rollbackFor = Exception.class) + public R> pullFromUpstream(Long hotelId) { + CanteenHotelEntity hotel = requireHotel(hotelId); + String hotelCode = hotel.getHotelCode(); + List upstreamTips = new ArrayList<>(); + + log.info("|酒店拉取上游|开始|hotelId={}|hotelCode={}|hotelName={}|", hotelId, hotelCode, hotel.getHotelName()); + + // 1) 综合信息:静态酒店 + 房型 + JSONObject complexResp = upstreamClient.getHotelComplexListRaw(Collections.singletonList(hotelCode)); + String complexMsg = complexResp.getString("message"); + JSONArray complexList = asContentArray(complexResp.get("content")); + JSONObject complex = complexList.isEmpty() ? new JSONObject() : complexList.getJSONObject(0); + if (complex.isEmpty()) { + String tip = StrUtil.blankToDefault(complexMsg, "getHotelComplexList content 为空"); + upstreamTips.add("综合信息: " + tip); + log.warn("|酒店拉取上游|综合信息无数据|hotelCode={}|code={}|message={}|", hotelCode, + complexResp.getString("code"), complexMsg); + } + else { + log.info("|酒店拉取上游|综合信息|hotelCode={}|roomTypeSize={}|hotelName={}|", hotelCode, + complex.getJSONArray("hotelRoomTypeList") == null ? 0 + : complex.getJSONArray("hotelRoomTypeList").size(), + complex.getString("hotelName")); + } + applyHotelComplex(hotel, complex); + hotelMapper.updateById(hotel); + int roomCount = upsertRoomsFromComplex(hotel, complex); + + // 2) 房价房量:按 30 天切片拉取,合并产品与日历 + int priceDays = Math.max(1, Math.min(90, + upstreamProperties.getPriceDays() > 0 ? upstreamProperties.getPriceDays() + : downstreamProperties.getDailyDays())); + LocalDate start = LocalDate.now(); + LocalDate endExclusive = start.plusDays(priceDays); + + Map productMap = new LinkedHashMap<>(); + List dailyDrafts = new ArrayList<>(); + Set roomTypesFromPrice = new HashSet<>(); + + LocalDate cursor = start; + int priceChunk = 0; + while (cursor.isBefore(endExclusive)) { + LocalDate chunkEnd = cursor.plusDays(ROOM_PRICE_CHUNK_DAYS); + if (chunkEnd.isAfter(endExclusive)) { + chunkEnd = endExclusive; + } + priceChunk++; + JSONObject priceResp = upstreamClient.getRoomPriceRaw(hotelCode, cursor.toString(), chunkEnd.toString(), + null); + String priceMsg = priceResp.getString("message"); + JSONObject priceContent = firstContentObject(priceResp.get("content")); + int rateSize = priceContent.getJSONArray("roomRateList") == null ? 0 + : priceContent.getJSONArray("roomRateList").size(); + log.info("|酒店拉取上游|房价切片|hotelCode={}|chunk={}|{}~{}|rateSize={}|message={}|", hotelCode, priceChunk, + cursor, chunkEnd, rateSize, priceMsg); + if (rateSize == 0 && StrUtil.isNotBlank(priceMsg) && priceChunk == 1) { + upstreamTips.add("房价房量: " + priceMsg); + } + mergeRoomPrice(hotel, priceContent, productMap, dailyDrafts, roomTypesFromPrice); + cursor = chunkEnd; + } + + ensureRoomsBatch(hotel, roomTypesFromPrice); + int productCount = persistProducts(hotel, productMap); + int dailyCount = persistDailies(hotel, dailyDrafts); + + if (roomCount == 0 && productCount == 0) { + try { + ShangyouHotelClient.HotelPage samplePage = upstreamClient.pageHotelBriefs(null, 1, 5, false); + List sample = new ArrayList<>(); + for (ShangyouHotelClient.HotelBrief h : samplePage.records()) { + sample.add(h.hotelId() + "(" + h.hotelName() + ")"); + } + String tip = "当前 hotelCode=" + hotelCode + " 无明细。账号下已授权酒店示例: " + sample + "(共" + + samplePage.total() + "家,可在「上游授权酒店」查看)"; + upstreamTips.add(tip); + log.warn("|酒店拉取上游|建议核对酒店编码|hotelCode={}|authorizedSample={}|total={}|", hotelCode, sample, + samplePage.total()); + } + catch (Exception e) { + log.warn("|酒店拉取上游|查询授权酒店列表失败|err={}|", e.getMessage()); + } + } + + Map result = new HashMap<>(8); + result.put("roomCount", roomCount); + result.put("productCount", productCount); + result.put("dailyCount", dailyCount); + result.put("priceDays", priceDays); + result.put("hotelCode", hotelCode); + result.put("markupPercent", priceConfigService.currentMarkupPercent()); + result.put("upstreamTips", upstreamTips); + log.info("|酒店拉取上游|结束|hotelCode={}|room={}|product={}|daily={}|tips={}|", hotelCode, roomCount, + productCount, dailyCount, upstreamTips); + return R.ok(result); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R> pullPriceRange(Long hotelId, LocalDate startInclusive, LocalDate endExclusive) { + CanteenHotelEntity hotel = requireHotel(hotelId); + if (startInclusive == null || endExclusive == null || !startInclusive.isBefore(endExclusive)) { + return R.failed("日期区间无效"); + } + String hotelCode = hotel.getHotelCode(); + // 若本地尚无房型,先补综合信息 + Long roomCnt = roomMapper.selectCount(Wrappers.lambdaQuery() + .eq(CanteenHotelRoomEntity::getHotelId, hotelId)); + if (roomCnt == null || roomCnt == 0) { + JSONObject complexResp = upstreamClient.getHotelComplexListRaw(Collections.singletonList(hotelCode)); + JSONArray complexList = asContentArray(complexResp.get("content")); + JSONObject complex = complexList.isEmpty() ? new JSONObject() : complexList.getJSONObject(0); + applyHotelComplex(hotel, complex); + hotelMapper.updateById(hotel); + upsertRoomsFromComplex(hotel, complex); + } + + Map productMap = new LinkedHashMap<>(); + List dailyDrafts = new ArrayList<>(); + Set roomTypesFromPrice = new HashSet<>(); + LocalDate cursor = startInclusive; + while (cursor.isBefore(endExclusive)) { + LocalDate chunkEnd = cursor.plusDays(ROOM_PRICE_CHUNK_DAYS); + if (chunkEnd.isAfter(endExclusive)) { + chunkEnd = endExclusive; + } + JSONObject priceResp = upstreamClient.getRoomPriceRaw(hotelCode, cursor.toString(), chunkEnd.toString(), + null); + JSONObject priceContent = firstContentObject(priceResp.get("content")); + mergeRoomPrice(hotel, priceContent, productMap, dailyDrafts, roomTypesFromPrice); + cursor = chunkEnd; + } + ensureRoomsBatch(hotel, roomTypesFromPrice); + int productCount = persistProducts(hotel, productMap); + int dailyCount = persistDailies(hotel, dailyDrafts); + Map result = new HashMap<>(6); + result.put("hotelCode", hotelCode); + result.put("productCount", productCount); + result.put("dailyCount", dailyCount); + result.put("start", startInclusive.toString()); + result.put("endExclusive", endExclusive.toString()); + return R.ok(result); + } + + private static JSONArray asContentArray(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 JSONObject firstContentObject(Object content) { + if (content instanceof JSONObject) { + return (JSONObject) content; + } + if (content instanceof JSONArray arr && !arr.isEmpty()) { + return arr.getJSONObject(0); + } + return new JSONObject(); + } + + @Override + public R> pushToDownstream(Long hotelId) { + CanteenHotelEntity hotel = requireHotel(hotelId); + String extHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId())); + + Map hotelBody = new LinkedHashMap<>(); + hotelBody.put("extHotelId", extHotelId); + hotelBody.put("hotelName", StrUtil.blankToDefault(hotel.getHotelName(), extHotelId)); + parseRemarkAddress(hotel.getRemark(), hotelBody); + downstreamClient.hotelSync(hotelBody); + + List rooms = roomMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelRoomEntity::getHotelId, hotelId)); + List products = productMapper + .selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelProductEntity::getHotelId, hotelId)); + Map roomQtyMap = new HashMap<>(); + for (CanteenHotelRoomEntity room : rooms) { + int qty = room.getRoomNumber() == null ? 0 : room.getRoomNumber(); + if (qty <= 0) { + qty = products.stream() + .filter(p -> StrUtil.equals(room.getRoomTypeCode(), p.getRoomTypeCode())) + .map(p -> p.getStock() == null ? 0 : p.getStock()) + .max(Integer::compareTo) + .orElse(1); + if (qty <= 0) { + qty = 1; + } + } + roomQtyMap.put(room.getRoomTypeCode(), qty); + } + if (CollUtil.isNotEmpty(rooms)) { + List> list = new ArrayList<>(); + for (CanteenHotelRoomEntity room : rooms) { + Map item = new LinkedHashMap<>(); + item.put("extApartmentId", room.getRoomTypeCode()); + item.put("apartmentName", StrUtil.blankToDefault(room.getRoomTypeName(), room.getRoomTypeCode())); + item.put("bedType", room.getBedType()); + item.put("capacity", room.getCapacity()); + item.put("apartmentNumber", roomQtyMap.getOrDefault(room.getRoomTypeCode(), 1)); + list.add(item); + } + for (List> batch : CollUtil.split(list, 50)) { + Map body = new LinkedHashMap<>(); + body.put("extHotelId", extHotelId); + body.put("list", batch); + downstreamClient.apartmentSync(body); + } + } + + List productErrors = new ArrayList<>(); + int productOk = 0; + Set pricePlanKeys = new HashSet<>(); + for (CanteenHotelProductEntity p : products) { + String roomCode = StrUtil.blankToDefault(p.getRoomTypeCode(), "_"); + int breakfast = p.getMealCount() == null ? 0 : p.getMealCount(); + int canCancel = p.getCanCancel() == null ? 0 : p.getCanCancel(); + String cancelRule = StrUtil.nullToEmpty(p.getCancelRule()); + String planKey = roomCode + "|" + breakfast + "|" + canCancel + "|" + cancelRule; + if (!pricePlanKeys.add(planKey)) { + productErrors.add(p.getProductCode() + ":跳过,同房型下早餐/取消规则与已推产品重复"); + continue; + } + Map body = new LinkedHashMap<>(); + body.put("extHotelId", extHotelId); + body.put("extApartmentId", roomCode); + body.put("extProductId", p.getProductCode()); + body.put("breakfastNumber", breakfast); + body.put("apartmentType", p.getApartmentType() == null ? 1 : p.getApartmentType()); + body.put("productType", p.getProductType() == null ? 2 : p.getProductType()); + body.put("canCancel", canCancel); + if (StrUtil.isNotBlank(p.getCancelRule())) { + body.put("cancelRule", p.getCancelRule()); + } + BigDecimal price = p.getSellPrice() != null ? p.getSellPrice() : p.getCostPrice(); + body.put("homePrice", price == null || price.compareTo(BigDecimal.ZERO) <= 0 ? BigDecimal.ONE : price); + body.put("homeNumber", roomQtyMap.getOrDefault(roomCode, 1)); + try { + downstreamClient.productSync(body); + productOk++; + } + catch (Exception ex) { + productErrors.add(p.getProductCode() + ":" + ex.getMessage()); + log.warn("|下游产品推送失败|hotelId={}|product={}|err={}", hotelId, p.getProductCode(), + ex.getMessage()); + } + } + + List dailies = dailyMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelDailyEntity::getHotelId, hotelId) + .ge(CanteenHotelDailyEntity::getStayDate, LocalDate.now()) + .orderByAsc(CanteenHotelDailyEntity::getStayDate)); + int dailyPush = 0; + List> dailyBatch = new ArrayList<>(); + for (CanteenHotelDailyEntity d : dailies) { + Map item = new LinkedHashMap<>(); + item.put("extApartmentId", d.getRoomTypeCode()); + item.put("extProductId", d.getProductCode()); + item.put("startDate", d.getStayDate().toString()); + item.put("endDate", d.getStayDate().toString()); + item.put("dailyPrice", d.getSellPrice()); + item.put("dailyNumber", d.getStock()); + item.put("dailyStatus", d.getDailyStatus()); + dailyBatch.add(item); + if (dailyBatch.size() >= 50) { + Map body = new LinkedHashMap<>(); + body.put("extHotelId", extHotelId); + body.put("list", dailyBatch); + downstreamClient.dailySync(body); + dailyPush += dailyBatch.size(); + dailyBatch = new ArrayList<>(); + } + } + if (!dailyBatch.isEmpty()) { + Map body = new LinkedHashMap<>(); + body.put("extHotelId", extHotelId); + body.put("list", dailyBatch); + downstreamClient.dailySync(body); + dailyPush += dailyBatch.size(); + } + + Map result = new HashMap<>(8); + result.put("roomCount", rooms.size()); + result.put("productCount", products.size()); + result.put("productPushOk", productOk); + result.put("productErrors", productErrors); + result.put("dailyPushCount", dailyPush); + return R.ok(result); + } + + @Override + public R> pullAndPush(Long hotelId) { + R> pull = pullFromUpstream(hotelId); + if (pull.getCode() != 0) { + return pull; + } + R> push = pushToDownstream(hotelId); + Map merged = new HashMap<>(); + if (pull.getData() != null) { + merged.putAll(pull.getData()); + } + if (push.getData() != null) { + merged.putAll(push.getData()); + } + return push.getCode() == 0 ? R.ok(merged) : push; + } + + @Override + public R> pullAndPushBatch(List hotelIds) { + int ok = 0; + List errors = new ArrayList<>(); + for (Long id : hotelIds) { + try { + R> r = pullAndPush(id); + if (r.getCode() == 0) { + ok++; + } + else { + errors.add(id + ":" + r.getMsg()); + } + } + catch (Exception e) { + errors.add(id + ":" + e.getMessage()); + } + } + Map 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 getHotelDetail(Long hotelId) { + CanteenHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null) { + return R.failed("酒店不存在"); + } + CanteenHotelDetailVO vo = new CanteenHotelDetailVO(); + vo.setHotel(hotel); + vo.setRooms(roomMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelRoomEntity::getHotelId, hotelId) + .orderByAsc(CanteenHotelRoomEntity::getRoomTypeCode))); + vo.setProducts(productMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelProductEntity::getHotelId, hotelId) + .orderByAsc(CanteenHotelProductEntity::getProductCode))); + Long dailyCount = dailyMapper.selectCount(Wrappers.lambdaQuery() + .eq(CanteenHotelDailyEntity::getHotelId, hotelId) + .ge(CanteenHotelDailyEntity::getStayDate, LocalDate.now())); + vo.setDailyCount(dailyCount); + vo.setDailyPreview(dailyMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelDailyEntity::getHotelId, hotelId) + .ge(CanteenHotelDailyEntity::getStayDate, LocalDate.now()) + .orderByAsc(CanteenHotelDailyEntity::getStayDate) + .orderByAsc(CanteenHotelDailyEntity::getProductCode) + .last("limit 30"))); + return R.ok(vo); + } + + @Override + public R> pageDaily(Long hotelId, Page page, + String productCode) { + Page result = dailyMapper.selectPage(page, + Wrappers.lambdaQuery() + .eq(CanteenHotelDailyEntity::getHotelId, hotelId) + .eq(StrUtil.isNotBlank(productCode), CanteenHotelDailyEntity::getProductCode, productCode) + .ge(CanteenHotelDailyEntity::getStayDate, LocalDate.now()) + .orderByAsc(CanteenHotelDailyEntity::getStayDate) + .orderByAsc(CanteenHotelDailyEntity::getProductCode)); + return R.ok(result); + } + + @Override + public R> listAuthorizedHotels(String keyword, long current, long size, boolean refresh) { + if (current < 1) { + current = 1; + } + if (size < 1) { + size = 20; + } + if (size > 100) { + size = 100; + } + long t0 = System.currentTimeMillis(); + ShangyouHotelClient.HotelPage hotelPage = upstreamClient.pageHotelBriefs(keyword, current, size, refresh); + // 用 Redis SET 判本地是否存在,避免几万 hotel_code 进 JVM + ensureLocalCodeSetWarm(); + List> page = new ArrayList<>(hotelPage.records().size()); + for (ShangyouHotelClient.HotelBrief item : hotelPage.records()) { + Map row = new LinkedHashMap<>(4); + row.put("hotelId", item.hotelId()); + row.put("hotelName", item.hotelName()); + Boolean exists = stringRedisTemplate.opsForSet() + .isMember(ShangyouHotelApi.REDIS_LOCAL_CODE_SET, item.hotelId()); + row.put("localExists", Boolean.TRUE.equals(exists)); + page.add(row); + } + Map result = new LinkedHashMap<>(); + result.put("records", page); + result.put("total", hotelPage.total()); + result.put("current", current); + result.put("size", size); + result.put("refreshed", refresh); + result.put("costMs", System.currentTimeMillis() - t0); + log.info("|上游授权酒店列表|total={}|page={}/{}|keyword={}|refresh={}|costMs={}|", hotelPage.total(), current, + size, keyword, refresh, result.get("costMs")); + return R.ok(result); + } + + /** 本地 code SET 为空时按 id 游标分批重建(不一次装全表) */ + private void ensureLocalCodeSetWarm() { + Long sz = stringRedisTemplate.opsForSet().size(ShangyouHotelApi.REDIS_LOCAL_CODE_SET); + if (sz != null && sz > 0) { + return; + } + rebuildLocalHotelCodeSet(); + } + + @Override + public long rebuildLocalHotelCodeSet() { + stringRedisTemplate.delete(ShangyouHotelApi.REDIS_LOCAL_CODE_SET); + long lastId = 0L; + long total = 0L; + int batch = Math.max(100, upstreamProperties.getDiffScanBatchSize()); + while (true) { + List rows = hotelMapper.selectList(Wrappers.lambdaQuery() + .select(CanteenHotelEntity::getId, CanteenHotelEntity::getHotelCode) + .gt(CanteenHotelEntity::getId, lastId) + .orderByAsc(CanteenHotelEntity::getId) + .last("limit " + batch)); + if (CollUtil.isEmpty(rows)) { + break; + } + List codes = new ArrayList<>(rows.size()); + for (CanteenHotelEntity h : rows) { + lastId = h.getId(); + if (StrUtil.isNotBlank(h.getHotelCode())) { + codes.add(h.getHotelCode()); + } + } + if (!codes.isEmpty()) { + stringRedisTemplate.opsForSet().add(ShangyouHotelApi.REDIS_LOCAL_CODE_SET, + codes.toArray(new String[0])); + total += codes.size(); + } + if (rows.size() < batch) { + break; + } + } + stringRedisTemplate.expire(ShangyouHotelApi.REDIS_LOCAL_CODE_SET, 2, java.util.concurrent.TimeUnit.DAYS); + log.info("|本地酒店编码SET|重建完成|count={}|", total); + return total; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R> importAuthorizedHotels(List> hotels) { + if (CollUtil.isEmpty(hotels)) { + return R.failed("请选择要导入的酒店"); + } + int insert = 0; + int skip = 0; + for (Map row : hotels) { + String hotelId = row.get("hotelId") == null ? null : String.valueOf(row.get("hotelId")); + String hotelName = row.get("hotelName") == null ? null : String.valueOf(row.get("hotelName")); + if (StrUtil.isBlank(hotelId)) { + skip++; + continue; + } + Long cnt = hotelMapper.selectCount(Wrappers.lambdaQuery() + .eq(CanteenHotelEntity::getHotelCode, hotelId)); + if (cnt != null && cnt > 0) { + skip++; + continue; + } + CanteenHotelEntity hotel = new CanteenHotelEntity(); + hotel.setHotelCode(hotelId); + hotel.setHotelName(StrUtil.blankToDefault(hotelName, hotelId)); + hotel.setSheetName("上游授权列表"); + hotel.setUpstreamStatus("ACTIVE"); + hotelMapper.insert(hotel); + stringRedisTemplate.opsForSet().add(ShangyouHotelApi.REDIS_LOCAL_CODE_SET, hotelId); + insert++; + } + Map result = new HashMap<>(4); + result.put("insertCount", insert); + result.put("skipCount", skip); + return R.ok(result); + } + + private CanteenHotelEntity requireHotel(Long hotelId) { + if (hotelId == null) { + throw new IllegalArgumentException("酒店ID不能为空"); + } + CanteenHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null) { + throw new IllegalArgumentException("酒店不存在"); + } + if (StrUtil.isBlank(hotel.getHotelCode())) { + throw new IllegalArgumentException("酒店缺少 hotelCode(对应上游 hotelId)"); + } + if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) { + throw new IllegalArgumentException("酒店已因上游下线停用,禁止拉取/推送"); + } + return hotel; + } + + private void applyHotelComplex(CanteenHotelEntity hotel, 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 int upsertRoomsFromComplex(CanteenHotelEntity hotel, JSONObject complex) { + if (complex == null) { + return 0; + } + JSONArray list = complex.getJSONArray("hotelRoomTypeList"); + if (list == null || list.isEmpty()) { + return 0; + } + Map existMap = loadRoomMap(hotel.getId()); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + Long tenantId = resolveTenantId(); + for (int i = 0; i < list.size(); i++) { + JSONObject item = list.getJSONObject(i); + String code = item.getString("roomTypeCode"); + if (StrUtil.isBlank(code)) { + continue; + } + CanteenHotelRoomEntity exist = existMap.get(code); + CanteenHotelRoomEntity room = exist == null ? new CanteenHotelRoomEntity() : exist; + room.setHotelId(hotel.getId()); + room.setGroupCode(hotel.getGroupCode()); + room.setHotelCode(hotel.getHotelCode()); + room.setRoomTypeCode(code); + room.setRoomTypeName(item.getString("roomTypeName")); + room.setBedType(item.getString("bedType")); + room.setCapacity(item.getInteger("maxCheckInPeoNum")); + room.setRoomNumber(item.getInteger("roomNum")); + room.setRawJson(item.toJSONString()); + room.setUpdateTime(now); + if (exist == null) { + fillNewMeta(room, tenantId, now); + toInsert.add(room); + existMap.put(code, room); + } + else { + toUpdate.add(room); + } + } + insertRoomsBatch(toInsert); + updateRoomsBatch(toUpdate); + return toInsert.size() + toUpdate.size(); + } + + /** + * 合并房价房量:产品维度 = rateCode + roomTypeId;日历按 bizDate 展开。 + * 房型不在此落库,收集到 roomTypesOut,最后 {@link #ensureRoomsBatch} 一次补齐。 + */ + private void mergeRoomPrice(CanteenHotelEntity hotel, JSONObject priceContent, + Map productMap, List dailyDrafts, + Set roomTypesOut) { + if (priceContent == null || priceContent.isEmpty()) { + return; + } + JSONArray rateList = priceContent.getJSONArray("roomRateList"); + if (rateList == null || rateList.isEmpty()) { + return; + } + for (int i = 0; i < rateList.size(); i++) { + JSONObject rate = rateList.getJSONObject(i); + String rateCode = rate.getString("rateCode"); + if (StrUtil.isBlank(rateCode)) { + continue; + } + int breakfast = resolveBreakfast(rate); + int canCancel = 0; + String cancelRule = null; + JSONObject cancelInfo = rate.getJSONObject("cancelInfo"); + if (cancelInfo != null) { + Boolean can = cancelInfo.getBoolean("isCanCancel"); + canCancel = Boolean.TRUE.equals(can) ? 1 : 0; + cancelRule = cancelInfo.getString("cancelDesc"); + } + + Map> stockByRoomDate = new HashMap<>(); + JSONArray countList = rate.getJSONArray("roomCountDailyList"); + if (countList != null) { + for (int c = 0; c < countList.size(); c++) { + JSONObject row = countList.getJSONObject(c); + String roomTypeId = row.getString("roomTypeId"); + String bizDate = row.getString("bizDate"); + if (StrUtil.hasBlank(roomTypeId, bizDate)) { + continue; + } + Integer available = row.getInteger("availableCount"); + Boolean forbidden = row.getBoolean("isForbidden"); + Boolean black = row.getBoolean("isBlack"); + int stock = available == null ? 0 : available; + if (Boolean.TRUE.equals(forbidden) || Boolean.TRUE.equals(black)) { + stock = 0; + } + stockByRoomDate.computeIfAbsent(roomTypeId, k -> new HashMap<>()).put(bizDate, stock); + } + } + + JSONArray priceDailyList = rate.getJSONArray("priceDailyList"); + if (priceDailyList == null || priceDailyList.isEmpty()) { + continue; + } + for (int p = 0; p < priceDailyList.size(); p++) { + JSONObject priceRow = priceDailyList.getJSONObject(p); + String roomTypeId = priceRow.getString("roomTypeId"); + String bizDate = priceRow.getString("bizDate"); + if (StrUtil.hasBlank(roomTypeId, bizDate)) { + continue; + } + Boolean forbidden = priceRow.getBoolean("isForbidden"); + Boolean black = priceRow.getBoolean("isBlack"); + BigDecimal cost = firstNonNullDecimal(priceRow.getBigDecimal("afterTaxCNYPrice"), + priceRow.getBigDecimal("afterTaxPrice"), priceRow.getBigDecimal("companyToGroupCNYPrice"), + priceRow.getBigDecimal("beforeTaxCNYPrice")); + if (cost == null || cost.compareTo(BigDecimal.ZERO) <= 0) { + cost = BigDecimal.ONE; + } + int stock = stockByRoomDate.getOrDefault(roomTypeId, Collections.emptyMap()) + .getOrDefault(bizDate, 0); + if (Boolean.TRUE.equals(forbidden) || Boolean.TRUE.equals(black)) { + stock = 0; + } + + String productCode = rateCode + "_" + roomTypeId; + CanteenHotelProductEntity product = productMap.get(productCode); + if (product == null) { + product = new CanteenHotelProductEntity(); + product.setHotelId(hotel.getId()); + product.setGroupCode(hotel.getGroupCode()); + product.setHotelCode(hotel.getHotelCode()); + product.setRoomTypeCode(roomTypeId); + product.setProductCode(productCode); + product.setProductName(StrUtil.blankToDefault(rate.getString("rateCodeName"), rateCode)); + product.setPriceCode(rateCode); + product.setMealCount(breakfast); + product.setMealType(breakfast > 0 ? 1 : 0); + product.setApartmentType(1); + product.setProductType(2); + product.setCanCancel(canCancel); + product.setCancelRule(cancelRule); + product.setCostPrice(cost); + product.setSellPrice(priceConfigService.applyMarkup(cost)); + product.setStock(stock); + product.setRawJson(rate.toJSONString()); + productMap.put(productCode, product); + } + else { + product.setCostPrice(cost); + product.setSellPrice(priceConfigService.applyMarkup(cost)); + if (stock > (product.getStock() == null ? 0 : product.getStock())) { + product.setStock(stock); + } + } + if (roomTypesOut != null) { + roomTypesOut.add(roomTypeId); + } + + DailyDraft draft = new DailyDraft(); + draft.productCode = productCode; + draft.roomTypeCode = roomTypeId; + draft.stayDate = LocalDate.parse(bizDate.substring(0, 10)); + draft.costPrice = cost; + draft.sellPrice = priceConfigService.applyMarkup(cost); + draft.stock = stock; + draft.dailyStatus = stock > 0 ? 1 : 0; + dailyDrafts.add(draft); + } + } + } + + /** 房价里出现但综合信息未含的房型,一次批量补齐 */ + private void ensureRoomsBatch(CanteenHotelEntity hotel, Set roomTypeCodes) { + if (CollUtil.isEmpty(roomTypeCodes)) { + return; + } + Map existMap = loadRoomMap(hotel.getId()); + List toInsert = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + Long tenantId = resolveTenantId(); + for (String code : roomTypeCodes) { + if (StrUtil.isBlank(code) || existMap.containsKey(code)) { + continue; + } + CanteenHotelRoomEntity room = new CanteenHotelRoomEntity(); + room.setHotelId(hotel.getId()); + room.setGroupCode(hotel.getGroupCode()); + room.setHotelCode(hotel.getHotelCode()); + room.setRoomTypeCode(code); + room.setRoomTypeName(code); + fillNewMeta(room, tenantId, now); + toInsert.add(room); + existMap.put(code, room); + } + insertRoomsBatch(toInsert); + } + + private int persistProducts(CanteenHotelEntity hotel, Map productMap) { + if (productMap == null || productMap.isEmpty()) { + return 0; + } + Map roomMap = loadRoomMap(hotel.getId()); + Map existMap = loadProductMap(hotel.getId()); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + Long tenantId = resolveTenantId(); + for (CanteenHotelProductEntity product : productMap.values()) { + CanteenHotelRoomEntity room = roomMap.get(product.getRoomTypeCode()); + product.setRoomId(room == null ? null : room.getId()); + CanteenHotelProductEntity exist = existMap.get(product.getProductCode()); + product.setUpdateTime(now); + if (exist == null) { + fillNewMeta(product, tenantId, now); + toInsert.add(product); + existMap.put(product.getProductCode(), product); + } + else { + product.setId(exist.getId()); + toUpdate.add(product); + } + } + insertProductsBatch(toInsert); + updateProductsBatch(toUpdate); + return toInsert.size() + toUpdate.size(); + } + + private int persistDailies(CanteenHotelEntity hotel, List drafts) { + dailyMapper.physicalDeleteFromDate(hotel.getId(), LocalDate.now()); + if (CollUtil.isEmpty(drafts)) { + return 0; + } + Map productIdMap = new HashMap<>(); + List products = productMapper + .selectList(Wrappers.lambdaQuery() + .select(CanteenHotelProductEntity::getId, CanteenHotelProductEntity::getProductCode) + .eq(CanteenHotelProductEntity::getHotelId, hotel.getId())); + for (CanteenHotelProductEntity p : products) { + productIdMap.put(p.getProductCode(), p.getId()); + } + Map unique = new LinkedHashMap<>(); + for (DailyDraft d : drafts) { + unique.put(d.productCode + "|" + d.stayDate, d); + } + LocalDateTime now = LocalDateTime.now(); + Long tenantId = resolveTenantId(); + List toInsert = new ArrayList<>(unique.size()); + for (DailyDraft d : unique.values()) { + Long productId = productIdMap.get(d.productCode); + if (productId == null) { + continue; + } + CanteenHotelDailyEntity daily = new CanteenHotelDailyEntity(); + daily.setHotelId(hotel.getId()); + daily.setProductId(productId); + daily.setRoomTypeCode(d.roomTypeCode); + daily.setProductCode(d.productCode); + daily.setStayDate(d.stayDate); + daily.setCostPrice(d.costPrice); + daily.setSellPrice(d.sellPrice); + daily.setStock(d.stock); + daily.setDailyStatus(d.dailyStatus); + fillNewMeta(daily, tenantId, now); + toInsert.add(daily); + } + insertDailiesBatch(toInsert); + return toInsert.size(); + } + + private Map loadRoomMap(Long hotelId) { + List rows = roomMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelRoomEntity::getHotelId, hotelId)); + Map map = new HashMap<>(Math.max(16, rows.size() * 2)); + for (CanteenHotelRoomEntity r : rows) { + if (StrUtil.isNotBlank(r.getRoomTypeCode())) { + map.put(r.getRoomTypeCode(), r); + } + } + return map; + } + + private Map loadProductMap(Long hotelId) { + List rows = productMapper + .selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelProductEntity::getHotelId, hotelId)); + Map map = new HashMap<>(Math.max(16, rows.size() * 2)); + for (CanteenHotelProductEntity p : rows) { + if (StrUtil.isNotBlank(p.getProductCode())) { + map.put(p.getProductCode(), p); + } + } + return map; + } + + private void insertRoomsBatch(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + for (int i = 0; i < list.size(); i += PERSIST_BATCH) { + roomMapper.insertBatch(list.subList(i, Math.min(i + PERSIST_BATCH, list.size()))); + } + } + + private void updateRoomsBatch(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + Db.updateBatchById(list, PERSIST_BATCH); + } + + private void insertProductsBatch(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + for (int i = 0; i < list.size(); i += PERSIST_BATCH) { + productMapper.insertBatch(list.subList(i, Math.min(i + PERSIST_BATCH, list.size()))); + } + } + + private void updateProductsBatch(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + Db.updateBatchById(list, PERSIST_BATCH); + } + + private void insertDailiesBatch(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + for (int i = 0; i < list.size(); i += PERSIST_BATCH) { + dailyMapper.insertBatch(list.subList(i, Math.min(i + PERSIST_BATCH, list.size()))); + } + } + + private static void fillNewMeta(CanteenHotelRoomEntity e, Long tenantId, LocalDateTime now) { + e.setId(IdWorker.getId()); + e.setTenantId(tenantId); + e.setCreateTime(now); + e.setUpdateTime(now); + e.setDelFlag(CommonConstants.STATUS_NORMAL); + } + + private static void fillNewMeta(CanteenHotelProductEntity e, Long tenantId, LocalDateTime now) { + e.setId(IdWorker.getId()); + e.setTenantId(tenantId); + e.setCreateTime(now); + e.setUpdateTime(now); + e.setDelFlag(CommonConstants.STATUS_NORMAL); + } + + private static void fillNewMeta(CanteenHotelDailyEntity e, Long tenantId, LocalDateTime now) { + e.setId(IdWorker.getId()); + e.setTenantId(tenantId); + e.setCreateTime(now); + e.setUpdateTime(now); + e.setDelFlag(CommonConstants.STATUS_NORMAL); + } + + private Long resolveTenantId() { + Long tid = TenantContextHolder.getTenantId(); + if (tid != null) { + return tid; + } + return upstreamProperties.getCallbackTenantId() == null ? 1L : upstreamProperties.getCallbackTenantId(); + } + + private static int resolveBreakfast(JSONObject rate) { + JSONArray list = rate.getJSONArray("memberAndBreakFastList"); + if (list == null || list.isEmpty()) { + return 0; + } + Integer count = list.getJSONObject(0).getInteger("breakfastCount"); + return count == null ? 0 : count; + } + + private static BigDecimal firstNonNullDecimal(BigDecimal... values) { + if (values == null) { + return null; + } + for (BigDecimal v : values) { + if (v != null) { + return v; + } + } + return null; + } + + private void parseRemarkAddress(String remark, Map hotelBody) { + if (StrUtil.isBlank(remark)) { + return; + } + for (String part : remark.split(";")) { + String[] kv = part.split("=", 2); + if (kv.length != 2) { + continue; + } + if ("city".equals(kv[0])) { + hotelBody.put("cityName", kv[1]); + } + else if ("addr".equals(kv[0])) { + hotelBody.put("address", kv[1]); + } + else if ("tel".equals(kv[0])) { + hotelBody.put("hotelPhone", kv[1]); + } + } + } + + private static class DailyDraft { + private String productCode; + private String roomTypeCode; + private LocalDate stayDate; + private BigDecimal costPrice; + private BigDecimal sellPrice; + private int stock; + private int dailyStatus; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelOrderServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelOrderServiceImpl.java new file mode 100644 index 000000000..d7371cdc0 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelOrderServiceImpl.java @@ -0,0 +1,915 @@ +package com.bsd.pjcloud.canteen.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.canteen.entity.*; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouBookStatus; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouHotelClient; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouOrderCallResult; +import com.bsd.pjcloud.canteen.mapper.*; +import com.bsd.pjcloud.canteen.service.CanteenHotelOrderService; +import com.bsd.pjcloud.canteen.service.CanteenHotelPriceConfigService; +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 CanteenHotelOrderServiceImpl extends ServiceImpl + implements CanteenHotelOrderService { + + /** 文档:下单后第 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 CanteenHotelDownstreamAppMapper appMapper; + + private final CanteenHotelMapper hotelMapper; + + private final CanteenHotelProductMapper productMapper; + + private final CanteenHotelDailyMapper dailyMapper; + + private final ShangyouHotelClient upstreamClient; + + private final CanteenHotelPriceConfigService priceConfigService; + + private final com.bsd.pjcloud.canteen.config.ShangyouHotelProperties shangyouProperties; + + @Override + public Page pageOrders(Page page, CanteenHotelOrderEntity query) { + return baseMapper.selectPage(page, Wrappers.lambdaQuery() + .like(StrUtil.isNotBlank(query.getChannelNo()), CanteenHotelOrderEntity::getChannelNo, query.getChannelNo()) + .like(StrUtil.isNotBlank(query.getPlatformNo()), CanteenHotelOrderEntity::getPlatformNo, + query.getPlatformNo()) + .eq(StrUtil.isNotBlank(query.getOrderStatus()), CanteenHotelOrderEntity::getOrderStatus, + query.getOrderStatus()) + .eq(StrUtil.isNotBlank(query.getBookStatus()), CanteenHotelOrderEntity::getBookStatus, query.getBookStatus()) + .eq(StrUtil.isNotBlank(query.getCompensateStatus()), CanteenHotelOrderEntity::getCompensateStatus, + query.getCompensateStatus()) + .eq(StrUtil.isNotBlank(query.getHotelCode()), CanteenHotelOrderEntity::getHotelCode, query.getHotelCode()) + .orderByDesc(CanteenHotelOrderEntity::getCreateTime)); + } + + @Override + public CanteenHotelOrderEntity getDetail(Long id) { + return baseMapper.selectById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R createFromDownstream(String appKey, Map body) { + CanteenHotelDownstreamAppEntity app = requireApp(appKey); + String channelNo = str(body.get("channelNo")); + if (StrUtil.isBlank(channelNo)) { + return R.failed("channelNo 不能为空"); + } + CanteenHotelOrderEntity exist = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelOrderEntity::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")); + CanteenHotelEntity hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelEntity::getHotelCode, hotelCode) + .eq(StrUtil.isNotBlank(groupCode), CanteenHotelEntity::getGroupCode, groupCode) + .last("limit 1")); + if (hotel == null) { + return R.failed("本地无此酒店,请先同步导入:" + hotelCode); + } + if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) { + return R.failed("酒店已停用,无法下单"); + } + CanteenHotelProductEntity product = null; + if (StrUtil.isNotBlank(productCode)) { + product = productMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelProductEntity::getHotelId, hotel.getId()) + .eq(CanteenHotelProductEntity::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"); + } + + CanteenHotelOrderEntity order = new CanteenHotelOrderEntity(); + 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 createByAdmin(Map 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)); + } + CanteenHotelEntity 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.lambdaQuery() + .eq(CanteenHotelEntity::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 不能为空"); + } + CanteenHotelProductEntity product = productMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelProductEntity::getHotelId, hotel.getId()) + .eq(CanteenHotelProductEntity::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); + } + CanteenHotelOrderEntity exist = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelOrderEntity::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; + } + + CanteenHotelOrderEntity order = new CanteenHotelOrderEntity(); + 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 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(CanteenHotelOrderEntity 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(CanteenHotelOrderEntity 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 due = baseMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelOrderEntity::getCompensateStatus, COMP_WAITING) + .le(CanteenHotelOrderEntity::getNextCompensateTime, now) + .orderByAsc(CanteenHotelOrderEntity::getNextCompensateTime) + .last("limit " + Math.max(1, limit))); + int n = 0; + for (CanteenHotelOrderEntity 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 compensateNow(Long id) { + CanteenHotelOrderEntity 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) { + CanteenHotelOrderEntity 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(CanteenHotelOrderEntity order) { + try { + Map body = StrUtil.isBlank(order.getReqJson()) ? Map.of() + : JSONUtil.parseObj(order.getReqJson()); + CanteenHotelProductEntity product = null; + if (order.getHotelId() != null && StrUtil.isNotBlank(order.getProductCode())) { + product = productMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelProductEntity::getHotelId, order.getHotelId()) + .eq(CanteenHotelProductEntity::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(CanteenHotelOrderEntity 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(CanteenHotelOrderEntity 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(CanteenHotelOrderEntity 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 handleOrderStatusNotify(Map body) { + // 由 UpstreamController 验签后调用;此处只更新订单,供异步队列或同步轻量调用 + String orderId = str(body.get("orderId")); + String outerRefId = str(body.get("outerRefId")); + String bookStatus = str(body.get("bookStatus")); + CanteenHotelOrderEntity order = null; + if (StrUtil.isNotBlank(orderId)) { + order = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelOrderEntity::getPlatformNo, orderId) + .last("limit 1")); + } + if (order == null && StrUtil.isNotBlank(outerRefId)) { + order = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelOrderEntity::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 cancelFromDownstream(String appKey, Map body) { + requireApp(appKey); + String channelNo = str(body.get("channelNo")); + CanteenHotelOrderEntity order = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelOrderEntity::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 queryFromDownstream(String appKey, Map body) { + requireApp(appKey); + String channelNo = str(body.get("channelNo")); + CanteenHotelOrderEntity order = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelOrderEntity::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 cancelByAdmin(Long id, String reason) { + CanteenHotelOrderEntity 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(CanteenHotelOrderEntity 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 请求体。 + *

+ * 字段名按官方文档:linkFirstName/linkMobile、guestInfoList、bookerPersonFirstName 等; + * dailyPriceList 必须覆盖入住夜(checkOut-checkIn),缺夜用产品底价补齐。 + */ + private com.alibaba.fastjson.JSONObject buildUpstreamOrderBody(CanteenHotelOrderEntity order, + CanteenHotelProductEntity product, Map 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()); + + // 文档必填:联系人用 linkFirstName / linkMobile(不是 linkName) + req.put("linkFirstName", order.getContactName()); + 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())); + req.put("bookerPersonFirstName", order.getContactName()); + req.put("bookerPersonMobile", order.getContactPhone()); + req.put("adults", downstreamBody.get("adults") == null ? 2 + : Integer.parseInt(String.valueOf(downstreamBody.get("adults")))); + + String guestName = order.getContactName(); + String guestMobile = order.getContactPhone(); + if (StrUtil.isNotBlank(order.getGuestJson())) { + try { + JSONArray guests = JSONUtil.parseArray(order.getGuestJson()); + if (guests != null && !guests.isEmpty()) { + JSONObject g0 = guests.getJSONObject(0); + guestName = StrUtil.blankToDefault( + StrUtil.blankToDefault(g0.getStr("guestFirstName"), g0.getStr("name")), guestName); + guestMobile = StrUtil.blankToDefault( + StrUtil.blankToDefault(g0.getStr("guestPhone"), g0.getStr("mobile")), guestMobile); + } + } + catch (Exception ignored) { + } + } + // 入住人:文档字段 guestInfoList(可选整体为空;传了则 guestFirstName/guestRoomNo 必填) + com.alibaba.fastjson.JSONArray guestInfoList = new com.alibaba.fastjson.JSONArray(); + com.alibaba.fastjson.JSONObject guest = new com.alibaba.fastjson.JSONObject(); + guest.put("guestFirstName", guestName); + guest.put("guestPhone", guestMobile); + guest.put("guestRoomNo", 1); + guestInfoList.add(guest); + 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 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(); + } + + /** + * 按住店夜补齐 dailyPriceList;本地日历缺夜则用产品底价兜底。 + */ + private List buildDailyPriceList(CanteenHotelOrderEntity order, + CanteenHotelProductEntity product) { + Map byDate = new java.util.LinkedHashMap<>(); + if (order.getHotelId() != null && StrUtil.isNotBlank(order.getProductCode()) + && order.getCheckInDate() != null && order.getCheckOutDate() != null) { + List dailies = dailyMapper + .selectList(Wrappers.lambdaQuery() + .eq(CanteenHotelDailyEntity::getHotelId, order.getHotelId()) + .eq(CanteenHotelDailyEntity::getProductCode, order.getProductCode()) + .ge(CanteenHotelDailyEntity::getStayDate, order.getCheckInDate()) + .lt(CanteenHotelDailyEntity::getStayDate, order.getCheckOutDate()) + .orderByAsc(CanteenHotelDailyEntity::getStayDate)); + for (CanteenHotelDailyEntity 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 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 CanteenHotelDownstreamAppEntity requireApp(String appKey) { + if (StrUtil.isBlank(appKey)) { + throw new IllegalArgumentException("缺少 appKey"); + } + CanteenHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.lambdaQuery() + .eq(CanteenHotelDownstreamAppEntity::getAppKey, appKey) + .eq(CanteenHotelDownstreamAppEntity::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)); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelPriceConfigServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelPriceConfigServiceImpl.java new file mode 100644 index 000000000..35cb166a4 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelPriceConfigServiceImpl.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.canteen.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.canteen.entity.CanteenHotelPriceConfigEntity; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelPriceConfigMapper; +import com.bsd.pjcloud.canteen.service.CanteenHotelPriceConfigService; +import com.bsd.pjcloud.common.core.util.R; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Service +@RequiredArgsConstructor +public class CanteenHotelPriceConfigServiceImpl extends ServiceImpl + implements CanteenHotelPriceConfigService { + + @Override + public R getCurrent() { + CanteenHotelPriceConfigEntity one = baseMapper.selectOne(Wrappers.lambdaQuery() + .orderByDesc(CanteenHotelPriceConfigEntity::getUpdateTime) + .last("limit 1")); + if (one == null) { + one = new CanteenHotelPriceConfigEntity(); + one.setMarkupPercent(BigDecimal.ZERO); + } + return R.ok(one); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R saveOrUpdateConfig(CanteenHotelPriceConfigEntity entity) { + if (entity.getMarkupPercent() == null || entity.getMarkupPercent().compareTo(BigDecimal.ZERO) < 0) { + return R.failed("加价百分比不能为空且不能为负"); + } + CanteenHotelPriceConfigEntity exist = baseMapper.selectOne(Wrappers.lambdaQuery() + .last("limit 1")); + if (exist == null) { + return R.ok(save(entity)); + } + exist.setMarkupPercent(entity.getMarkupPercent()); + exist.setRemark(entity.getRemark()); + return R.ok(updateById(exist)); + } + + @Override + public BigDecimal currentMarkupPercent() { + CanteenHotelPriceConfigEntity one = baseMapper.selectOne(Wrappers.lambdaQuery() + .last("limit 1")); + return one == null || one.getMarkupPercent() == null ? BigDecimal.ZERO : one.getMarkupPercent(); + } + + @Override + public BigDecimal applyMarkup(BigDecimal costPrice) { + if (costPrice == null) { + return null; + } + BigDecimal percent = currentMarkupPercent(); + // sell = cost * (1 + percent/100) + BigDecimal rate = BigDecimal.ONE.add(percent.divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP)); + return costPrice.multiply(rate).setScale(2, RoundingMode.HALF_UP); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelSyncServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelSyncServiceImpl.java new file mode 100644 index 000000000..cd69a4778 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelSyncServiceImpl.java @@ -0,0 +1,780 @@ +package com.bsd.pjcloud.canteen.service.impl; + +import cn.hutool.core.collection.CollUtil; +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.canteen.config.ShangyouHotelProperties; +import com.bsd.pjcloud.canteen.entity.CanteenHotelEntity; +import com.bsd.pjcloud.canteen.entity.CanteenHotelSyncLogEntity; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouHotelApi; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouHotelClient; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelMapper; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelSyncLogMapper; +import com.bsd.pjcloud.canteen.service.CanteenHotelMidService; +import com.bsd.pjcloud.canteen.service.CanteenHotelSyncService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * 酒店列表日更 + Redis SDIFF + 滚价;手动/定时均写同步日志并支持进度查询。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CanteenHotelSyncServiceImpl implements CanteenHotelSyncService { + + private static final String TYPE_LIST = "LIST_DIFF"; + + private static final String TYPE_ROLL = "PRICE_ROLL"; + + private static final String TYPE_DETAIL = "DETAIL_PULL"; + + private static final String STATUS_RUNNING = "RUNNING"; + + private static final String STATUS_SUCCESS = "SUCCESS"; + + private static final String STATUS_FAILED = "FAILED"; + + private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r, "canteen-hotel-sync"); + t.setDaemon(true); + return t; + }); + + private final ShangyouHotelProperties properties; + + private final ShangyouHotelClient upstreamClient; + + private final CanteenHotelMidService midService; + + private final CanteenHotelMapper hotelMapper; + + private final CanteenHotelSyncLogMapper syncLogMapper; + + private final StringRedisTemplate redisTemplate; + + @Override + public R startListSyncAsync(boolean autoImport, String triggerType) { + Long running = syncLogMapper.selectCount(Wrappers.lambdaQuery() + .eq(CanteenHotelSyncLogEntity::getSyncType, TYPE_LIST) + .eq(CanteenHotelSyncLogEntity::getStatus, STATUS_RUNNING)); + if (running != null && running > 0) { + return R.failed("已有列表同步任务进行中,请稍候查看进度"); + } + CanteenHotelSyncLogEntity logEntity = newLog(TYPE_LIST, triggerType); + logEntity.setStage("QUEUED"); + logEntity.setStageMsg("已提交,等待执行…"); + logEntity.setProgressPct(1); + syncLogMapper.insert(logEntity); + + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + } + final Long tid = tenantId; + final Long logId = logEntity.getId(); + final boolean doImport = autoImport; + EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runListSync(logId, doImport))); + return R.ok(logEntity, "已提交异步同步"); + } + + @Override + public R startPriceRollAsync(String triggerType) { + if (!properties.isPriceRollDailyEnabled() && !"MANUAL".equalsIgnoreCase(triggerType)) { + return R.failed("滚价未启用"); + } + Long running = syncLogMapper.selectCount(Wrappers.lambdaQuery() + .eq(CanteenHotelSyncLogEntity::getSyncType, TYPE_ROLL) + .eq(CanteenHotelSyncLogEntity::getStatus, STATUS_RUNNING)); + if (running != null && running > 0) { + return R.failed("已有滚价任务进行中"); + } + CanteenHotelSyncLogEntity logEntity = newLog(TYPE_ROLL, triggerType); + logEntity.setStage("QUEUED"); + logEntity.setStageMsg("已提交滚价任务…"); + logEntity.setProgressPct(1); + syncLogMapper.insert(logEntity); + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + } + final Long tid = tenantId; + final Long logId = logEntity.getId(); + EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runPriceRoll(logId))); + return R.ok(logEntity, "已提交异步滚价"); + } + + @Override + public R startDetailPullAsync(Integer limit, String triggerType) { + Long running = syncLogMapper.selectCount(Wrappers.lambdaQuery() + .eq(CanteenHotelSyncLogEntity::getSyncType, TYPE_DETAIL) + .eq(CanteenHotelSyncLogEntity::getStatus, STATUS_RUNNING)); + if (running != null && running > 0) { + return R.failed("已有全量详情拉取任务进行中,请打开「同步日志」查看进度"); + } + String quotaErr = checkFullPriceSyncQuota(); + if (quotaErr != null) { + return R.failed(quotaErr); + } + + int maxHotels = limit != null && limit > 0 ? limit : properties.getDetailPullMaxHotels(); + CanteenHotelSyncLogEntity logEntity = newLog(TYPE_DETAIL, triggerType); + logEntity.setStage("QUEUED"); + logEntity.setStageMsg(maxHotels > 0 ? "已提交,最多处理 " + maxHotels + " 家启用酒店…" + : "已提交,将拉取全部启用酒店详情(耗时长,请看进度)…"); + logEntity.setProgressPct(1); + syncLogMapper.insert(logEntity); + markFullPriceSyncStarted(); + + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + } + final Long tid = tenantId; + final Long logId = logEntity.getId(); + final int cap = maxHotels; + EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runDetailPull(logId, cap))); + return R.ok(logEntity, "已提交全量详情拉取"); + } + + @Override + public R getSyncLog(Long id) { + CanteenHotelSyncLogEntity e = syncLogMapper.selectById(id); + if (e == null) { + return R.failed("同步记录不存在"); + } + return R.ok(e); + } + + @Override + public Page pageSyncLogs(Page page, + CanteenHotelSyncLogEntity query) { + return syncLogMapper.selectPage(page, Wrappers.lambdaQuery() + .eq(StrUtil.isNotBlank(query.getSyncType()), CanteenHotelSyncLogEntity::getSyncType, query.getSyncType()) + .eq(StrUtil.isNotBlank(query.getStatus()), CanteenHotelSyncLogEntity::getStatus, query.getStatus()) + .orderByDesc(CanteenHotelSyncLogEntity::getCreateTime)); + } + + @Override + public R> syncHotelListAndDiff(boolean autoImport) { + R started = startListSyncAsync(autoImport, "MANUAL"); + if (started.getCode() != 0) { + return R.failed(started.getMsg()); + } + Map 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()); + } + + private void runListSync(Long logId, boolean autoImport) { + long t0 = System.currentTimeMillis(); + try { + progress(logId, 5, "REFRESH_LIST", "正在拉取上游授权酒店列表…"); + long upstreamCount = upstreamClient.refreshHotelListCache(); + + progress(logId, 25, "REBUILD_LOCAL", "正在重建本地酒店编码集合…"); + long localCount = midService.rebuildLocalHotelCodeSet(); + + progress(logId, 45, "DIFF", "正在计算新增/下线差集…"); + redisTemplate.delete(ShangyouHotelApi.REDIS_DIFF_NEW_SET); + redisTemplate.delete(ShangyouHotelApi.REDIS_DIFF_OFFLINE_SET); + Long newCnt = redisTemplate.opsForSet().differenceAndStore(ShangyouHotelApi.REDIS_UPSTREAM_ID_SET, + ShangyouHotelApi.REDIS_LOCAL_CODE_SET, ShangyouHotelApi.REDIS_DIFF_NEW_SET); + Long offlineCnt = redisTemplate.opsForSet().differenceAndStore(ShangyouHotelApi.REDIS_LOCAL_CODE_SET, + ShangyouHotelApi.REDIS_UPSTREAM_ID_SET, ShangyouHotelApi.REDIS_DIFF_OFFLINE_SET); + redisTemplate.expire(ShangyouHotelApi.REDIS_DIFF_NEW_SET, 2, TimeUnit.DAYS); + redisTemplate.expire(ShangyouHotelApi.REDIS_DIFF_OFFLINE_SET, 2, TimeUnit.DAYS); + + int imported = 0; + if (autoImport && properties.isAutoImportNewHotels()) { + progress(logId, 60, "IMPORT_NEW", "正在导入新增酒店(上限 " + + properties.getAutoImportBatchSize() + ")…"); + R> imp = importDiffNewHotels(properties.getAutoImportBatchSize()); + if (imp != null && imp.getData() != null && imp.getData().get("insertCount") != null) { + imported = Integer.parseInt(String.valueOf(imp.getData().get("insertCount"))); + } + } + + int offlineMarked = 0; + if (properties.isAutoMarkOfflineHotels()) { + progress(logId, 80, "MARK_OFFLINE", "正在将上游下线酒店标为停用…"); + offlineMarked = markOfflineFromDiff(); + } + int reactivated = 0; + if (properties.isAutoReactivateOnlineHotels()) { + progress(logId, 90, "REACTIVATE", "正在恢复重新授权的酒店…"); + reactivated = reactivateBackOnlineHotels(); + } + + CanteenHotelSyncLogEntity done = syncLogMapper.selectById(logId); + if (done == null) { + return; + } + done.setStatus(STATUS_SUCCESS); + done.setStage("DONE"); + done.setStageMsg("同步完成"); + done.setProgressPct(100); + done.setUpstreamCount(upstreamCount); + done.setLocalCount(localCount); + done.setNewCount(newCnt == null ? 0L : newCnt); + done.setOfflineCount(offlineCnt == null ? 0L : offlineCnt); + done.setImported(imported); + done.setOfflineMarked(offlineMarked); + done.setReactivated(reactivated); + done.setCostMs(System.currentTimeMillis() - t0); + done.setFinishTime(LocalDateTime.now()); + done.setUpdateTime(LocalDateTime.now()); + syncLogMapper.updateById(done); + log.info("|酒店列表同步|完成|logId={}|upstream={}|new={}|offlineMarked={}|costMs={}|", logId, upstreamCount, + newCnt, offlineMarked, done.getCostMs()); + } + catch (Exception e) { + log.error("|酒店列表同步|失败|logId={}|", logId, e); + failLog(logId, e.getMessage(), System.currentTimeMillis() - t0); + } + } + + private void runPriceRoll(Long logId) { + long t0 = System.currentTimeMillis(); + try { + progress(logId, 5, "ROLL", "开始滚动拉取第 N 天房价…"); + int offset = Math.max(1, properties.getPriceRollDayOffset()); + LocalDate day = LocalDate.now().plusDays(offset); + LocalDate end = day.plusDays(1); + int batchSize = Math.max(20, properties.getPriceRollBatchSize()); + long lastId = 0L; + int ok = 0; + int fail = 0; + int skip = 0; + int processed = 0; + List failLines = new ArrayList<>(); + Long totalHint = hotelMapper.selectCount(Wrappers.lambdaQuery() + .and(w -> w.isNull(CanteenHotelEntity::getUpstreamStatus) + .or() + .ne(CanteenHotelEntity::getUpstreamStatus, "OFFLINE"))); + long total = totalHint == null ? 0L : totalHint; + + while (true) { + List rows = hotelMapper.selectList(Wrappers.lambdaQuery() + .select(CanteenHotelEntity::getId, CanteenHotelEntity::getHotelCode, + CanteenHotelEntity::getHotelName) + .gt(CanteenHotelEntity::getId, lastId) + .and(w -> w.isNull(CanteenHotelEntity::getUpstreamStatus) + .or() + .ne(CanteenHotelEntity::getUpstreamStatus, "OFFLINE")) + .orderByAsc(CanteenHotelEntity::getId) + .last("limit " + batchSize)); + if (CollUtil.isEmpty(rows)) { + break; + } + for (CanteenHotelEntity h : rows) { + lastId = h.getId(); + processed++; + if (StrUtil.isBlank(h.getHotelCode())) { + skip++; + continue; + } + try { + R> r = midService.pullPriceRange(h.getId(), day, end); + if (r != null && r.getCode() == 0) { + ok++; + } + else { + fail++; + appendFail(failLines, h, r == null ? "返回为空" : StrUtil.blankToDefault(r.getMsg(), "业务失败")); + } + } + catch (Exception e) { + fail++; + appendFail(failLines, h, e.getMessage()); + } + } + int pct = total <= 0 ? 50 : Math.min(95, 5 + (int) (processed * 90L / total)); + progress(logId, pct, "ROLL", + "滚价中 day=" + day + " 已处理 " + processed + "/" + total + " 成功 " + ok + " 失败 " + fail); + if (rows.size() < batchSize) { + break; + } + } + + CanteenHotelSyncLogEntity done = syncLogMapper.selectById(logId); + if (done == null) { + return; + } + done.setStatus(STATUS_SUCCESS); + done.setStage("DONE"); + done.setStageMsg("滚价完成 day=" + day); + done.setProgressPct(100); + done.setSuccessCount(ok); + done.setFailCount(fail); + done.setSkipCount(skip); + done.setLocalCount(total); + applyFailDetail(done, failLines, fail); + done.setCostMs(System.currentTimeMillis() - t0); + done.setFinishTime(LocalDateTime.now()); + done.setUpdateTime(LocalDateTime.now()); + syncLogMapper.updateById(done); + } + catch (Exception e) { + log.error("|酒店滚价|失败|logId={}|", logId, e); + failLog(logId, e.getMessage(), System.currentTimeMillis() - t0); + } + } + + /** + * 对本地全部启用酒店逐家 pullFromUpstream(综合信息 + 未来 price-days 房价)。 + */ + private void runDetailPull(Long logId, int maxHotels) { + long t0 = System.currentTimeMillis(); + try { + progress(logId, 3, "SCAN", "正在统计启用酒店…"); + Long totalHint = hotelMapper.selectCount(Wrappers.lambdaQuery() + .and(w -> w.isNull(CanteenHotelEntity::getUpstreamStatus) + .or() + .ne(CanteenHotelEntity::getUpstreamStatus, "OFFLINE"))); + long totalAll = totalHint == null ? 0L : totalHint; + long target = maxHotels > 0 ? Math.min(totalAll, maxHotels) : totalAll; + int batchSize = Math.max(20, properties.getPriceRollBatchSize()); + int intervalMs = Math.max(0, properties.getDetailPullIntervalMs()); + long lastId = 0L; + int ok = 0; + int fail = 0; + int skip = 0; + int processed = 0; + List failLines = new ArrayList<>(); + + progress(logId, 5, "PULL", "开始拉取详情:目标约 " + target + " 家(price-days=" + + properties.getPriceDays() + ")…"); + + outer: + while (true) { + List rows = hotelMapper.selectList(Wrappers.lambdaQuery() + .select(CanteenHotelEntity::getId, CanteenHotelEntity::getHotelCode, + CanteenHotelEntity::getHotelName) + .gt(CanteenHotelEntity::getId, lastId) + .and(w -> w.isNull(CanteenHotelEntity::getUpstreamStatus) + .or() + .ne(CanteenHotelEntity::getUpstreamStatus, "OFFLINE")) + .orderByAsc(CanteenHotelEntity::getId) + .last("limit " + batchSize)); + if (CollUtil.isEmpty(rows)) { + break; + } + for (CanteenHotelEntity h : rows) { + lastId = h.getId(); + if (maxHotels > 0 && processed >= maxHotels) { + break outer; + } + processed++; + if (StrUtil.isBlank(h.getHotelCode())) { + skip++; + continue; + } + try { + R> r = midService.pullFromUpstream(h.getId()); + if (r != null && r.getCode() == 0) { + ok++; + } + else { + fail++; + appendFail(failLines, h, r == null ? "返回为空" : StrUtil.blankToDefault(r.getMsg(), "业务失败")); + } + } + catch (Exception e) { + fail++; + appendFail(failLines, h, e.getMessage()); + log.warn("|全量详情|单店失败|hotelId={}|code={}|{}", h.getId(), h.getHotelCode(), e.getMessage()); + } + int pct = target <= 0 ? 50 : Math.min(95, 5 + (int) (processed * 90L / target)); + progress(logId, pct, "PULL", "拉取中 " + processed + "/" + target + " 成功 " + ok + " 失败 " + fail + + " 当前 " + StrUtil.blankToDefault(h.getHotelName(), h.getHotelCode())); + if (intervalMs > 0) { + try { + Thread.sleep(intervalMs); + } + catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + failLog(logId, "任务被中断", System.currentTimeMillis() - t0); + return; + } + } + } + if (rows.size() < batchSize) { + break; + } + } + + CanteenHotelSyncLogEntity done = syncLogMapper.selectById(logId); + if (done == null) { + return; + } + done.setStatus(STATUS_SUCCESS); + done.setStage("DONE"); + done.setStageMsg("全量详情拉取完成"); + done.setProgressPct(100); + done.setSuccessCount(ok); + done.setFailCount(fail); + done.setSkipCount(skip); + done.setLocalCount(totalAll); + applyFailDetail(done, failLines, fail); + done.setCostMs(System.currentTimeMillis() - t0); + done.setFinishTime(LocalDateTime.now()); + done.setUpdateTime(LocalDateTime.now()); + syncLogMapper.updateById(done); + log.info("|全量详情|完成|logId={}|ok={}|fail={}|skip={}|costMs={}|", logId, ok, fail, skip, done.getCostMs()); + } + catch (Exception e) { + log.error("|全量详情|失败|logId={}|", logId, e); + failLog(logId, e.getMessage(), System.currentTimeMillis() - t0); + } + } + + private static void appendFail(List failLines, CanteenHotelEntity h, String reason) { + if (failLines == null) { + return; + } + // 明细最多保留 500 行,避免撑爆字段 + if (failLines.size() >= 500) { + return; + } + String code = StrUtil.blankToDefault(h.getHotelCode(), String.valueOf(h.getId())); + String name = StrUtil.blankToDefault(h.getHotelName(), "-"); + failLines.add(code + "|" + name + "|" + StrUtil.maxLength(StrUtil.blankToDefault(reason, "未知错误"), 300)); + } + + private static void applyFailDetail(CanteenHotelSyncLogEntity done, List failLines, int fail) { + if (fail <= 0 || CollUtil.isEmpty(failLines)) { + return; + } + String detail = String.join("\n", failLines); + if (fail > failLines.size()) { + detail = detail + "\n… 另有 " + (fail - failLines.size()) + " 条未列出"; + } + done.setFailDetail(detail); + done.setErrorMsg(StrUtil.maxLength("失败 " + fail + " 家,示例: " + failLines.get(0), 1000)); + done.setStageMsg(StrUtil.maxLength("完成,成功见 successCount,失败明细见 failDetail", 500)); + } + + /** 文档限制:全量房价每天次数 + 最小间隔 */ + private String checkFullPriceSyncQuota() { + int maxPerDay = Math.max(1, properties.getFullPriceSyncMaxPerDay()); + int minHours = Math.max(0, properties.getFullPriceSyncMinIntervalHours()); + String last = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_FULL_PRICE_SYNC_LAST); + if (StrUtil.isNotBlank(last) && minHours > 0) { + try { + long lastMs = Long.parseLong(last.trim()); + long gap = System.currentTimeMillis() - lastMs; + if (gap < minHours * 3600_000L) { + long leftMin = (minHours * 3600_000L - gap) / 60_000L + 1; + return "距上次全量详情拉取不足 " + minHours + " 小时,约需再等 " + leftMin + " 分钟(上游文档限制)"; + } + } + catch (NumberFormatException ignored) { + // ignore + } + } + String countStr = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_FULL_PRICE_SYNC_COUNT); + int count = 0; + if (StrUtil.isNotBlank(countStr)) { + try { + count = Integer.parseInt(countStr.trim()); + } + catch (NumberFormatException ignored) { + count = 0; + } + } + if (count >= maxPerDay) { + return "今日全量详情拉取已达上限 " + maxPerDay + " 次(上游文档限制),请明天再试或只对单店点「拉取上游」"; + } + return null; + } + + private void markFullPriceSyncStarted() { + redisTemplate.opsForValue().set(ShangyouHotelApi.REDIS_FULL_PRICE_SYNC_LAST, + String.valueOf(System.currentTimeMillis()), 2, TimeUnit.DAYS); + Long c = redisTemplate.opsForValue().increment(ShangyouHotelApi.REDIS_FULL_PRICE_SYNC_COUNT); + if (c != null && c == 1L) { + redisTemplate.expire(ShangyouHotelApi.REDIS_FULL_PRICE_SYNC_COUNT, 1, TimeUnit.DAYS); + } + } + + private CanteenHotelSyncLogEntity newLog(String syncType, String triggerType) { + CanteenHotelSyncLogEntity e = new CanteenHotelSyncLogEntity(); + e.setSyncType(syncType); + e.setTriggerType(StrUtil.blankToDefault(triggerType, "MANUAL")); + e.setStatus(STATUS_RUNNING); + e.setProgressPct(0); + e.setDelFlag("0"); + e.setCreateTime(LocalDateTime.now()); + e.setUpdateTime(LocalDateTime.now()); + try { + var user = SecurityUtils.getUser(); + if (user != null) { + e.setOperator(user.getUsername()); + e.setCreateBy(user.getUsername()); + } + } + catch (Exception ignored) { + e.setOperator("system"); + e.setCreateBy("system"); + } + return e; + } + + private void progress(Long logId, int pct, String stage, String msg) { + CanteenHotelSyncLogEntity e = syncLogMapper.selectById(logId); + if (e == null) { + return; + } + e.setProgressPct(pct); + e.setStage(stage); + e.setStageMsg(StrUtil.maxLength(msg, 500)); + e.setUpdateTime(LocalDateTime.now()); + syncLogMapper.updateById(e); + } + + private void failLog(Long logId, String err, long costMs) { + CanteenHotelSyncLogEntity e = syncLogMapper.selectById(logId); + if (e == null) { + return; + } + e.setStatus(STATUS_FAILED); + e.setStage("FAILED"); + e.setStageMsg("执行失败"); + e.setErrorMsg(StrUtil.maxLength(err, 1000)); + e.setCostMs(costMs); + e.setFinishTime(LocalDateTime.now()); + e.setUpdateTime(LocalDateTime.now()); + syncLogMapper.updateById(e); + } + + private int markOfflineFromDiff() { + int updated = 0; + int batch = Math.max(50, properties.getDiffScanBatchSize()); + List codes = new ArrayList<>(batch); + ScanOptions options = ScanOptions.scanOptions().count(batch).build(); + try (Cursor cursor = redisTemplate.opsForSet().scan(ShangyouHotelApi.REDIS_DIFF_OFFLINE_SET, options)) { + while (cursor.hasNext()) { + codes.add(cursor.next()); + if (codes.size() >= batch) { + updated += updateStatusByCodes(codes, "OFFLINE"); + codes.clear(); + } + } + } + catch (Exception e) { + log.warn("|酒店停用|扫描 offline 差集失败|{}", e.getMessage()); + } + if (!codes.isEmpty()) { + updated += updateStatusByCodes(codes, "OFFLINE"); + } + return updated; + } + + private int reactivateBackOnlineHotels() { + int updated = 0; + long lastId = 0L; + int batch = Math.max(50, properties.getDiffScanBatchSize()); + while (true) { + List rows = hotelMapper.selectList(Wrappers.lambdaQuery() + .select(CanteenHotelEntity::getId, CanteenHotelEntity::getHotelCode) + .eq(CanteenHotelEntity::getUpstreamStatus, "OFFLINE") + .gt(CanteenHotelEntity::getId, lastId) + .orderByAsc(CanteenHotelEntity::getId) + .last("limit " + batch)); + if (CollUtil.isEmpty(rows)) { + break; + } + List back = new ArrayList<>(); + for (CanteenHotelEntity h : rows) { + lastId = h.getId(); + if (StrUtil.isBlank(h.getHotelCode())) { + continue; + } + if (upstreamClient.isUpstreamHotel(h.getHotelCode())) { + back.add(h.getHotelCode()); + } + } + if (!back.isEmpty()) { + updated += updateStatusByCodes(back, "ACTIVE"); + } + if (rows.size() < batch) { + break; + } + } + return updated; + } + + private int updateStatusByCodes(List codes, String status) { + if (CollUtil.isEmpty(codes)) { + return 0; + } + return hotelMapper.update(null, Wrappers.lambdaUpdate() + .set(CanteenHotelEntity::getUpstreamStatus, status) + .in(CanteenHotelEntity::getHotelCode, codes)); + } + + @Override + public R> pageHotelDiff(String type, long current, long size) { + String key = "offline".equalsIgnoreCase(type) ? ShangyouHotelApi.REDIS_DIFF_OFFLINE_SET + : ShangyouHotelApi.REDIS_DIFF_NEW_SET; + if (current < 1) { + current = 1; + } + if (size < 1) { + size = 20; + } + if (size > 100) { + size = 100; + } + Long total = redisTemplate.opsForSet().size(key); + long totalVal = total == null ? 0L : total; + List> records = new ArrayList<>((int) size); + if (totalVal > 0) { + long skip = (current - 1) * size; + long matched = 0; + long need = skip + size; + ScanOptions options = ScanOptions.scanOptions().count(Math.max(100, properties.getDiffScanBatchSize())) + .build(); + try (Cursor cursor = redisTemplate.opsForSet().scan(key, options)) { + while (cursor.hasNext() && matched < need) { + String hotelId = cursor.next(); + if (matched >= skip) { + Map row = new LinkedHashMap<>(4); + row.put("hotelId", hotelId); + row.put("hotelName", upstreamClient.hotelNameOf(hotelId)); + row.put("type", "offline".equalsIgnoreCase(type) ? "offline" : "new"); + records.add(row); + } + matched++; + } + } + catch (Exception e) { + log.warn("|酒店差集分页|scan 异常|{}", e.getMessage()); + } + } + Map result = new LinkedHashMap<>(); + result.put("records", records); + result.put("total", totalVal); + result.put("current", current); + result.put("size", size); + result.put("type", type); + return R.ok(result); + } + + @Override + public R> importDiffNewHotels(Integer limit) { + int max = limit == null || limit <= 0 ? properties.getAutoImportBatchSize() : limit; + max = Math.min(Math.max(max, 1), 5000); + List> batch = new ArrayList<>(); + ScanOptions options = ScanOptions.scanOptions().count(Math.max(100, properties.getDiffScanBatchSize())).build(); + try (Cursor cursor = redisTemplate.opsForSet().scan(ShangyouHotelApi.REDIS_DIFF_NEW_SET, options)) { + while (cursor.hasNext() && batch.size() < max) { + String hotelId = cursor.next(); + Map row = new HashMap<>(2); + row.put("hotelId", hotelId); + row.put("hotelName", upstreamClient.hotelNameOf(hotelId)); + batch.add(row); + } + } + catch (Exception e) { + return R.failed("扫描新增差集失败: " + e.getMessage()); + } + if (batch.isEmpty()) { + Map empty = new HashMap<>(2); + empty.put("insertCount", 0); + empty.put("skipCount", 0); + return R.ok(empty, "无待导入酒店"); + } + R> r = midService.importAuthorizedHotels(batch); + if (r != null && r.getCode() == 0) { + for (Map row : batch) { + Object id = row.get("hotelId"); + if (id != null) { + redisTemplate.opsForSet().remove(ShangyouHotelApi.REDIS_DIFF_NEW_SET, String.valueOf(id)); + } + } + midService.rebuildLocalHotelCodeSet(); + } + return r; + } + + @Override + public Map getSyncSchedule() { + Map m = new LinkedHashMap<>(); + m.put("springScheduleEnabled", properties.isSpringScheduleEnabled()); + m.put("hotelListDailySyncEnabled", properties.isHotelListDailySyncEnabled()); + m.put("hotelListDailyCron", properties.getHotelListDailyCron()); + m.put("priceRollDailyEnabled", properties.isPriceRollDailyEnabled()); + m.put("priceRollDailyCron", properties.getPriceRollDailyCron()); + m.put("autoImportNewHotels", properties.isAutoImportNewHotels()); + m.put("autoMarkOfflineHotels", properties.isAutoMarkOfflineHotels()); + m.put("hint", "改 Nacos/本地 shangyou-hotel.hotel-list-daily-cron、price-roll-daily-cron;" + + "若用 XXL 请设 spring-schedule-enabled=false,在 XXL 控制台配 Cron"); + return m; + } + + @Override + public Map jobSyncHotelList() { + return jobSyncHotelList("XXL"); + } + + @Override + public Map jobSyncHotelList(String triggerType) { + Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + return TenantBroker.applyAs(tenantId, tid -> { + if (!properties.isHotelListDailySyncEnabled()) { + return Map.of("skipped", true, "reason", "hotel-list-daily-sync-enabled=false"); + } + R r = startListSyncAsync(properties.isAutoImportNewHotels(), + StrUtil.blankToDefault(triggerType, "XXL")); + if (r.getCode() != 0) { + return Map.of("ok", false, "msg", r.getMsg()); + } + return Map.of("ok", true, "async", true, "logId", r.getData().getId()); + }); + } + + @Override + public Map jobRollDailyPrice() { + return jobRollDailyPrice("XXL"); + } + + @Override + public Map jobRollDailyPrice(String triggerType) { + Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + return TenantBroker.applyAs(tenantId, tid -> { + if (!properties.isPriceRollDailyEnabled()) { + return Map.of("skipped", true, "reason", "price-roll-daily-enabled=false"); + } + R r = startPriceRollAsync(StrUtil.blankToDefault(triggerType, "XXL")); + if (r.getCode() != 0) { + return Map.of("ok", false, "msg", r.getMsg()); + } + return Map.of("ok", true, "async", true, "logId", r.getData().getId()); + }); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelUpstreamServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelUpstreamServiceImpl.java new file mode 100644 index 000000000..82c15c5a9 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelUpstreamServiceImpl.java @@ -0,0 +1,214 @@ +package com.bsd.pjcloud.canteen.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.canteen.entity.CanteenHotelEntity; +import com.bsd.pjcloud.canteen.integration.shangyou.ShangyouHotelClient; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelMapper; +import com.bsd.pjcloud.canteen.service.CanteenHotelCallbackTaskService; +import com.bsd.pjcloud.canteen.service.CanteenHotelUpstreamService; +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 CanteenHotelUpstreamServiceImpl implements CanteenHotelUpstreamService { + + private final ShangyouHotelClient client; + + private final CanteenHotelMapper hotelMapper; + + private final CanteenHotelCallbackTaskService callbackTaskService; + + @Override + @Transactional(rollbackFor = Exception.class) + public R syncHotelInfo(Long hotelId) { + if (hotelId == null) { + return R.failed("酒店ID不能为空"); + } + CanteenHotelEntity 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> syncHotelInfoBatch(List hotelIds) { + if (CollUtil.isEmpty(hotelIds)) { + return R.failed("请选择酒店"); + } + int ok = 0; + List errors = new ArrayList<>(); + for (Long id : hotelIds) { + try { + R 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 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 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 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 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 orderCreate(Map body) { + com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject(body); + return R.ok(toHutool(client.createOrder(req))); + } + + @Override + public R orderCancel(Map 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 orderGetById(Map 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 orderCheck(Map body) { + com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject(body); + return R.ok(toHutool(client.checkAvailability(req))); + } + + @Override + public R handleNotify(Map body) { + return handleNotify(body, null, null); + } + + @Override + public R handleNotify(Map body, String time, String sign) { + Map 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 handleShangyouNotify(String notifyType, Map body, String time, + String sign) { + return callbackTaskService.enqueueFromNotify(notifyType, body, time, sign); + } + + private void applyHotelComplex(CanteenHotelEntity 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 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); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml index 0f500689e..7e4e8bd92 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml @@ -28,4 +28,102 @@ security: encrypt: enable: true # 开启请求加解密 default-encrypt-type: AES # 可选:AES、SM4,默认 AES - aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位 \ No newline at end of file + aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位 + +# ========== 玉编电云 / 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 + # ----- 日更 / 差集 ----- + # 酒店列表日同步开关(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 diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelDailyMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelDailyMapper.xml new file mode 100644 index 000000000..04023adf7 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelDailyMapper.xml @@ -0,0 +1,19 @@ + + + + + + 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 + + ( + #{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} + ) + + + diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelProductMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelProductMapper.xml new file mode 100644 index 000000000..e7f94e4c3 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelProductMapper.xml @@ -0,0 +1,22 @@ + + + + + + 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 + + ( + #{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} + ) + + + diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelRoomMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelRoomMapper.xml new file mode 100644 index 000000000..51fff1248 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelRoomMapper.xml @@ -0,0 +1,19 @@ + + + + + + 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 + + ( + #{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} + ) + + + From a3c7699716c158cd298c83b954912e69661afa21 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Tue, 29 Sep 2026 21:44:58 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pjcloud-canteen-biz/src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml index 7e4e8bd92..c181c0c5b 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml @@ -91,7 +91,7 @@ shangyou-hotel: # 列表同步后是否自动把「上游有本地无」写入本地主数据 auto-import-new-hotels: true # 自动导入单次上限(剩余留在差集,可再点导入) - auto-import-batch-size: 2000 + auto-import-batch-size: 5000 # 每日滚第 N 天房价开关 price-roll-daily-enabled: true # 滚价偏移:today + N(文档第 91 天 → 90) From 9013f51e2a6fcb7be6e8c16a8a9ecf7919e88abb Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Wed, 30 Sep 2026 11:16:14 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canteen/entity/CanteenHotelEntity.java | 15 ++ .../config/ShangyouHotelProperties.java | 21 +- .../controller/CanteenHotelMidController.java | 18 ++ .../shangyou/ShangyouCallbackQueueWorker.java | 26 ++- .../shangyou/ShangyouHotelApi.java | 3 + .../shangyou/ShangyouHotelClient.java | 209 ++++++++++++++++-- .../mapper/CanteenHotelDailyMapper.java | 13 ++ .../mapper/CanteenHotelProductMapper.java | 13 ++ .../mapper/CanteenHotelRoomMapper.java | 13 ++ .../CanteenHotelCallbackTaskService.java | 4 +- .../service/CanteenHotelSyncService.java | 5 + .../CanteenHotelCallbackTaskServiceImpl.java | 33 +-- .../impl/CanteenHotelOrderServiceImpl.java | 70 +++++- .../service/impl/CanteenHotelServiceImpl.java | 52 ++++- .../impl/CanteenHotelSyncServiceImpl.java | 198 ++++++++++++++++- .../src/main/resources/application.yml | 198 +++++++++-------- 16 files changed, 740 insertions(+), 151 deletions(-) diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java index 00871b24b..42e0b9236 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java @@ -66,4 +66,19 @@ public class CanteenHotelEntity extends Model { @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; } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java index 38c6a5adf..3da1d31ae 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java @@ -93,6 +93,20 @@ public class ShangyouHotelProperties { /** 单条回调任务最大尝试次数(含首次) */ 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 则跳过) */ @@ -131,14 +145,17 @@ public class ShangyouHotelProperties { /** 滚价每批处理本地酒店数(按 id 游标,不装全表) */ private int priceRollBatchSize = 100; + /** 滚价每家间隔毫秒,减轻上游限流/繁忙(默认 30) */ + private int priceRollIntervalMs = 30; + /** 文档:全量房价房量每天最多次数 */ private int fullPriceSyncMaxPerDay = 3; /** 文档:全量房价两次间隔最少小时数 */ private int fullPriceSyncMinIntervalHours = 6; - /** 全量拉详情:每家酒店间隔毫秒(防打爆上游;落库已批量,默认 0) */ - private int detailPullIntervalMs = 0; + /** 全量拉详情:每家酒店间隔毫秒(防打爆上游;落库已批量,默认 50) */ + private int detailPullIntervalMs = 50; /** 全量拉详情单次最多处理家数;0=不限制(慎用,酒店多时耗时长) */ private int detailPullMaxHotels = 0; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java index 107ff819e..a85f91cae 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java @@ -194,4 +194,22 @@ public class CanteenHotelMidController { data.put("message", "已提交,请轮询 /mid/syncLog/{id} 或打开同步日志"); return R.ok(data, started.getMsg()); } + + @SysLog("重试同步失败酒店") + @Operation(summary = "根据某次同步日志的失败明细,只重试失败酒店(详情/滚价)") + @PostMapping("/mid/retryFailedFromLog") + @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + public R> retryFailedFromLog(@RequestParam Long logId) { + R started = syncService.startRetryFailedAsync(logId); + if (started.getCode() != 0) { + return R.failed(started.getMsg()); + } + Map 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()); + } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java index bdfaeec3e..2e3e04c39 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java @@ -51,6 +51,7 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner { } private void loop() { + int consecutiveFail = 0; while (running.get()) { try { String taskId = redisTemplate.opsForList() @@ -66,7 +67,30 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner { log.warn("|回调队列|非法 taskId={}|", taskId); continue; } - callbackTaskService.processQueuedTask(id); + 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()) { diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java index 739e85192..677847e01 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java @@ -57,4 +57,7 @@ public final class ShangyouHotelApi { /** 上次全量房价同步时间 epochMs */ public static final String REDIS_FULL_PRICE_SYNC_LAST = "canteen:shangyou:hotel:fullprice:last"; + + /** 房价接口 QPS 秒窗计数前缀,完整 key = 前缀 + ":" + epochSecond */ + public static final String REDIS_PRICE_QPS_PREFIX = "canteen:shangyou:hotel:price:qps"; } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java index 4d2c45588..3e4c1beec 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java @@ -61,6 +61,12 @@ public class ShangyouHotelClient { return getAccessToken(false); } + /** + * 取 Token。forceRefresh=true 时先删缓存再换新。 + *

+ * 用 Redis 锁互斥刷新;抢不到锁时等待其他线程写好缓存,避免并发各自去打 oauth、 + * 或「删掉旧 Token 后还没写上新 Token」窗口里读到空/脏值。 + */ public String getAccessToken(boolean forceRefresh) { ensureEnabled(); if (!forceRefresh) { @@ -74,20 +80,60 @@ public class ShangyouHotelClient { } Boolean locked = redisTemplate.opsForValue() .setIfAbsent(ShangyouHotelApi.REDIS_TOKEN_LOCK, "1", 30, TimeUnit.SECONDS); - try { - if (!forceRefresh) { + if (Boolean.TRUE.equals(locked)) { + try { + if (!forceRefresh) { + String cached = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_TOKEN_KEY); + if (StrUtil.isNotBlank(cached)) { + return cached; + } + } + return fetchAndCacheToken(); + } + finally { + redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_LOCK); + } + } + // 未抢到锁:等持锁线程把 Token 写入 Redis + String waited = waitForCachedToken(10_000L); + if (StrUtil.isNotBlank(waited)) { + return waited; + } + // 超时兜底:再抢一次锁自行刷新 + Boolean locked2 = redisTemplate.opsForValue() + .setIfAbsent(ShangyouHotelApi.REDIS_TOKEN_LOCK, "1", 30, TimeUnit.SECONDS); + if (Boolean.TRUE.equals(locked2)) { + try { String cached = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_TOKEN_KEY); if (StrUtil.isNotBlank(cached)) { return cached; } + return fetchAndCacheToken(); } - return fetchAndCacheToken(); - } - finally { - if (Boolean.TRUE.equals(locked)) { + finally { redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_LOCK); } } + throw new CheckedException("获取上游 Token 超时(刷新锁竞争)"); + } + + /** 轮询 Redis 直到出现 Token 或超时 */ + private String waitForCachedToken(long timeoutMs) { + long deadline = System.currentTimeMillis() + Math.max(1000L, timeoutMs); + while (System.currentTimeMillis() < deadline) { + String cached = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_TOKEN_KEY); + if (StrUtil.isNotBlank(cached)) { + return cached; + } + try { + Thread.sleep(150L); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + } + return redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_TOKEN_KEY); } private String fetchAndCacheToken() { @@ -378,6 +424,7 @@ public class ShangyouHotelClient { /** * 房价房量。返回完整上游响应(含 code/message/content)。 + * QPS 限流在 postBiz 内按次生效(含重试)。 */ public JSONObject getRoomPriceRaw(String hotelId, String checkInDate, String checkOutDate, List roomTypeIds) { @@ -399,6 +446,57 @@ public class ShangyouHotelClient { 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 roomTypeIds) { JSONObject resp = getRoomPriceRaw(hotelId, checkInDate, checkOutDate, roomTypeIds); @@ -578,17 +676,25 @@ public class ShangyouHotelClient { } private JSONObject postBiz(String path, JSONObject body) { - return postBiz(path, body, false); + return postBiz(path, body, 0, false); } /** - * 业务调用。文档约定业务码 504 = token 失效,需重新取 token 后再调一次。 + * 业务调用。 + *

+ * 文档:504=token 失效。但 getRoomPrice 高峰常把限流也打成 504(同一 Token 刚调通 + * getHotelComplexList 却对房价接口报 504)。策略:先同 Token 退避重试,再强制刷新, + * 繁忙同样退避;最多 4 轮。 */ - private JSONObject postBiz(String path, JSONObject body, boolean retried) { + private JSONObject postBiz(String path, JSONObject body, int retryRound, boolean forceToken) { ensureEnabled(); - String token = getAccessToken(retried); + if (isRoomPricePath(path)) { + acquirePriceApiPermit(); + } + String token = getAccessToken(forceToken); String url = properties.resolveBizBaseUrl() + path; - log.info("shangyou request path={} retried={} bodyKeys={}", path, retried, body.keySet()); + 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", "*/*") @@ -598,11 +704,25 @@ public class ShangyouHotelClient { .timeout(properties.getTimeoutMs()) .execute()) { String respBody = response.body(); - if (response.getStatus() == 401 || isTokenExpiredBiz(respBody)) { - redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_KEY); - if (!retried) { - log.warn("shangyou token expired, refresh and retry path={}", path); - return postBiz(path, body, true); + boolean httpUnauthorized = response.getStatus() == 401; + boolean tokenBizFail = isTokenExpiredBiz(respBody); + if (httpUnauthorized || tokenBizFail) { + if (retryRound < 4) { + boolean refreshNow = httpUnauthorized || retryRound >= 1; + if (refreshNow) { + redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_KEY); + } + long waitMs = switch (retryRound) { + case 0 -> 400L; + case 1 -> 1000L; + case 2 -> 2000L; + default -> 4000L; + }; + sleepQuiet(waitMs); + log.warn( + "shangyou token/limit code path={} nextRound={} refreshToken={} http401={} snippet={}", + path, retryRound + 1, refreshNow, httpUnauthorized, StrUtil.sub(respBody, 0, 200)); + return postBiz(path, body, retryRound + 1, refreshNow); } throw new CheckedException("上游 Token 失效且重试仍失败 path=" + path + " body=" + respBody); } @@ -612,13 +732,37 @@ public class ShangyouHotelClient { } JSONObject resp = JSON.parseObject(respBody); if (isTokenExpiredBiz(resp)) { - redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_KEY); - if (!retried) { - log.warn("shangyou biz code token invalid, refresh and retry path={}", path); - return postBiz(path, body, true); + if (retryRound < 4) { + boolean refreshNow = retryRound >= 1; + if (refreshNow) { + redisTemplate.delete(ShangyouHotelApi.REDIS_TOKEN_KEY); + } + long waitMs = switch (retryRound) { + case 0 -> 400L; + case 1 -> 1000L; + case 2 -> 2000L; + default -> 4000L; + }; + sleepQuiet(waitMs); + log.warn("shangyou biz token/limit path={} nextRound={} refreshToken={} msg={}", path, + retryRound + 1, refreshNow, resp.getString("message")); + return postBiz(path, body, retryRound + 1, refreshNow); } throw new CheckedException("上游 Token 失效且重试仍失败 path=" + path + " body=" + respBody); } + // 上游瞬时繁忙:短暂退避后重试(不刷新 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); + } ensureBizOk(resp, path); // 酒店列表很大,只打数量,避免日志/字符串截取拖慢 if (ShangyouHotelApi.HOTEL_LIST.equals(path)) { @@ -663,6 +807,31 @@ public class ShangyouHotelClient { || 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); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java index 70ef041fb..46c4cd93d 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java @@ -5,9 +5,12 @@ 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 java.time.LocalDate; +import java.util.Collection; import java.util.List; +import java.util.Map; @Mapper public interface CanteenHotelDailyMapper extends PjcloudBaseMapper { @@ -19,4 +22,14 @@ public interface CanteenHotelDailyMapper extends PjcloudBaseMapper list); + + @Select(""" + + """) + List> countGroupByHotelIds(@Param("ids") Collection ids); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelProductMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelProductMapper.java index 3324bf672..8db0576cc 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelProductMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelProductMapper.java @@ -4,11 +4,24 @@ import com.bsd.pjcloud.canteen.entity.CanteenHotelProductEntity; 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 CanteenHotelProductMapper extends PjcloudBaseMapper { int insertBatch(@Param("list") List list); + + @Select(""" + + """) + List> countGroupByHotelIds(@Param("ids") Collection ids); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelRoomMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelRoomMapper.java index 50763c225..931a822a7 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelRoomMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelRoomMapper.java @@ -4,11 +4,24 @@ import com.bsd.pjcloud.canteen.entity.CanteenHotelRoomEntity; 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 CanteenHotelRoomMapper extends PjcloudBaseMapper { int insertBatch(@Param("list") List list); + + @Select(""" + + """) + List> countGroupByHotelIds(@Param("ids") Collection ids); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelCallbackTaskService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelCallbackTaskService.java index f090b9d1e..ed2e8e3c8 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelCallbackTaskService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelCallbackTaskService.java @@ -26,8 +26,8 @@ public interface CanteenHotelCallbackTaskService { /** 失败/跳过任务重新入队 */ R retry(Long id); - /** 消费一条队列任务(Worker 调用) */ - void processQueuedTask(Long taskId); + /** 消费一条队列任务(Worker 调用);返回 true=本次成功/跳过,false=失败或待再试 */ + boolean processQueuedTask(Long taskId); /** 启动时把滞留 PENDING 重新入队 */ int requeueStalePending(); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelSyncService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelSyncService.java index d489ba0ac..870860f75 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelSyncService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelSyncService.java @@ -39,6 +39,11 @@ public interface CanteenHotelSyncService { */ R startDetailPullAsync(Integer limit, String triggerType); + /** + * 根据某次同步日志的 failDetail,只重试失败酒店(支持 DETAIL_PULL / PRICE_ROLL)。 + */ + R startRetryFailedAsync(Long sourceLogId); + /** 兼容旧同步入口(内部转异步) */ R> syncHotelListAndDiff(boolean autoImport); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelCallbackTaskServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelCallbackTaskServiceImpl.java index 4282cd7eb..a29706f69 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelCallbackTaskServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelCallbackTaskServiceImpl.java @@ -140,12 +140,13 @@ public class CanteenHotelCallbackTaskServiceImpl implements CanteenHotelCallback } @Override - public void processQueuedTask(Long taskId) { + public boolean processQueuedTask(Long taskId) { if (taskId == null) { - return; + return true; } Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); - TenantBroker.runAs(tenantId, tid -> doProcess(taskId)); + Boolean ok = TenantBroker.applyAs(tenantId, tid -> doProcess(taskId)); + return Boolean.TRUE.equals(ok); } @Override @@ -158,7 +159,8 @@ public class CanteenHotelCallbackTaskServiceImpl implements CanteenHotelCallback .last("limit 200")); int n = 0; for (CanteenHotelCallbackTaskEntity t : list) { - redisTemplate.opsForList().leftPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, String.valueOf(t.getId())); + // 队尾恢复,避免一启动就顶着打 + redisTemplate.opsForList().rightPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, String.valueOf(t.getId())); n++; } if (n > 0) { @@ -168,15 +170,16 @@ public class CanteenHotelCallbackTaskServiceImpl implements CanteenHotelCallback }); } - private void doProcess(Long taskId) { + /** @return true 成功或跳过;false 失败/待重试 */ + private boolean doProcess(Long taskId) { CanteenHotelCallbackTaskEntity task = taskMapper.selectById(taskId); if (task == null) { log.warn("|回调队列|任务不存在|taskId={}|", taskId); - return; + return true; } if (!STATUS_PENDING.equals(task.getStatus())) { log.info("|回调队列|跳过非 PENDING|taskId={}|status={}|", taskId, task.getStatus()); - return; + return true; } int attempts = task.getAttemptCount() == null ? 0 : task.getAttemptCount(); int max = Math.max(1, properties.getCallbackMaxAttempts()); @@ -193,7 +196,7 @@ public class CanteenHotelCallbackTaskServiceImpl implements CanteenHotelCallback : JSONUtil.parseObj(task.getReqJson()); Map result = orderService.handleOrderStatusNotify(body); finish(task, STATUS_SUCCESS, "订单状态更新: " + JSONUtil.toJsonStr(result), t0); - return; + return true; } if (task.getHotelId() == null && StrUtil.isNotBlank(task.getHotelCode())) { CanteenHotelEntity hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() @@ -207,22 +210,23 @@ public class CanteenHotelCallbackTaskServiceImpl implements CanteenHotelCallback } if (task.getHotelId() == null) { finish(task, STATUS_SKIPPED, "本地无匹配酒店(hotelCode=" + task.getHotelCode() + "),请先导入/授权", t0); - return; + return true; } R> pull = midService.pullFromUpstream(task.getHotelId()); 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; } - else { - String msg = pull == null ? "拉取返回空" : StrUtil.blankToDefault(pull.getMsg(), "拉取失败"); - handleFailOrRetry(task, msg, t0, max); - } + String msg = pull == null ? "拉取返回空" : StrUtil.blankToDefault(pull.getMsg(), "拉取失败"); + handleFailOrRetry(task, msg, t0, max); + return false; } catch (Exception e) { log.error("|回调队列|处理异常|taskId={}|", taskId, e); handleFailOrRetry(task, StrUtil.maxLength(e.getMessage(), 800), t0, max); + return false; } } @@ -234,7 +238,8 @@ public class CanteenHotelCallbackTaskServiceImpl implements CanteenHotelCallback task.setCostMs(System.currentTimeMillis() - t0); task.setUpdateTime(LocalDateTime.now()); taskMapper.updateById(task); - redisTemplate.opsForList().leftPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, String.valueOf(task.getId())); + // 放到队尾,避免刚失败立刻再打同一家(原先 leftPush 会顶到队头) + redisTemplate.opsForList().rightPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE, String.valueOf(task.getId())); return; } finish(task, STATUS_FAILED, msg, t0); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelOrderServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelOrderServiceImpl.java index d7371cdc0..00137ff6b 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelOrderServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelOrderServiceImpl.java @@ -776,8 +776,10 @@ public class CanteenHotelOrderServiceImpl extends ServiceImpl + *

  • 中文:首字为姓,其余为名(复姓未特殊处理;单字名时名为 "/")
  • + *
  • 英文空格分隔:按 First Last 或 Last First 取两段
  • + *
  • 其它:整段进 LastName,FirstName="/"
  • + * + */ + 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;本地日历缺夜则用产品底价兜底。 */ diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelServiceImpl.java index bcecc62d9..cf2f90e7c 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelServiceImpl.java @@ -15,7 +15,10 @@ 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.canteen.entity.CanteenHotelEntity; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelDailyMapper; import com.bsd.pjcloud.canteen.mapper.CanteenHotelMapper; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelProductMapper; +import com.bsd.pjcloud.canteen.mapper.CanteenHotelRoomMapper; import com.bsd.pjcloud.canteen.service.CanteenHotelService; import com.bsd.pjcloud.canteen.vo.CanteenHotelImportResultVO; import com.bsd.pjcloud.common.core.constant.CommonConstants; @@ -53,10 +56,57 @@ public class CanteenHotelServiceImpl extends ServiceImpl IMPORT_TASKS = new ConcurrentHashMap<>(); + private final CanteenHotelRoomMapper roomMapper; + + private final CanteenHotelProductMapper productMapper; + + private final CanteenHotelDailyMapper dailyMapper; + @Override public Page pageScope(Page page, LambdaQueryWrapper wrapper) { - return baseMapper.selectPageByScope(page, wrapper, DataScope.of()); + Page result = baseMapper.selectPageByScope(page, wrapper, DataScope.of()); + fillDetailCounts(result.getRecords()); + return result; + } + + /** 为本页酒店批量补齐房型/产品/日历数量,避免 N+1 */ + private void fillDetailCounts(List records) { + if (CollUtil.isEmpty(records)) { + return; + } + List ids = records.stream().map(CanteenHotelEntity::getId).filter(id -> id != null).toList(); + if (ids.isEmpty()) { + return; + } + Map roomMap = toCountMap(roomMapper.countGroupByHotelIds(ids)); + Map productMap = toCountMap(productMapper.countGroupByHotelIds(ids)); + Map dailyMap = toCountMap(dailyMapper.countGroupByHotelIds(ids)); + for (CanteenHotelEntity 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 toCountMap(List> rows) { + Map map = new HashMap<>(); + if (CollUtil.isEmpty(rows)) { + return map; + } + for (Map 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 diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelSyncServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelSyncServiceImpl.java index cd69a4778..8878628c9 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelSyncServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelSyncServiceImpl.java @@ -157,6 +157,53 @@ public class CanteenHotelSyncServiceImpl implements CanteenHotelSyncService { return R.ok(logEntity, "已提交全量详情拉取"); } + @Override + public R startRetryFailedAsync(Long sourceLogId) { + if (sourceLogId == null) { + return R.failed("请指定失败来源日志 logId"); + } + CanteenHotelSyncLogEntity src = syncLogMapper.selectById(sourceLogId); + if (src == null) { + return R.failed("同步记录不存在"); + } + if (STATUS_RUNNING.equals(src.getStatus())) { + return R.failed("原任务仍在执行,请结束后再重试失败项"); + } + String syncType = src.getSyncType(); + if (!TYPE_DETAIL.equals(syncType) && !TYPE_ROLL.equals(syncType)) { + return R.failed("仅支持「全量详情」或「滚价」的失败重试"); + } + List codes = parseFailHotelCodes(src.getFailDetail()); + if (CollUtil.isEmpty(codes)) { + return R.failed("该日志无失败明细可重试(请确认 failDetail 有 hotelCode|名称|原因)"); + } + Long running = syncLogMapper.selectCount(Wrappers.lambdaQuery() + .eq(CanteenHotelSyncLogEntity::getSyncType, syncType) + .eq(CanteenHotelSyncLogEntity::getStatus, STATUS_RUNNING)); + if (running != null && running > 0) { + return R.failed("已有同类型任务进行中,请稍后再试"); + } + + CanteenHotelSyncLogEntity logEntity = newLog(syncType, "RETRY"); + logEntity.setStage("QUEUED"); + logEntity.setStageMsg("重试失败酒店:来源日志 " + sourceLogId + ",共 " + codes.size() + " 家" + + (TYPE_ROLL.equals(syncType) ? "(滚价日=" + resolveRollDay(src) + ")" : "")); + logEntity.setProgressPct(1); + logEntity.setLocalCount((long) codes.size()); + syncLogMapper.insert(logEntity); + + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + } + final Long tid = tenantId; + final Long logId = logEntity.getId(); + final List codeList = codes; + final LocalDate rollDay = TYPE_ROLL.equals(syncType) ? resolveRollDay(src) : null; + EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runRetryFailed(logId, syncType, codeList, rollDay))); + return R.ok(logEntity, "已提交失败重试,共 " + codes.size() + " 家"); + } + @Override public R getSyncLog(Long id) { CanteenHotelSyncLogEntity e = syncLogMapper.selectById(id); @@ -311,6 +358,16 @@ public class CanteenHotelSyncServiceImpl implements CanteenHotelSyncService { fail++; appendFail(failLines, h, e.getMessage()); } + int intervalMs = Math.max(0, properties.getPriceRollIntervalMs()); + if (intervalMs > 0) { + try { + Thread.sleep(intervalMs); + } + catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } } int pct = total <= 0 ? 50 : Math.min(95, 5 + (int) (processed * 90L / total)); progress(logId, pct, "ROLL", @@ -452,12 +509,149 @@ public class CanteenHotelSyncServiceImpl implements CanteenHotelSyncService { } } + /** + * 仅重试某次失败明细里的酒店码(详情拉取或滚价)。 + */ + private void runRetryFailed(Long logId, String syncType, List hotelCodes, LocalDate rollDay) { + long t0 = System.currentTimeMillis(); + try { + progress(logId, 5, "SCAN", "正在按失败明细匹配本地酒店…"); + List hotels = new ArrayList<>(); + int batch = 200; + for (int i = 0; i < hotelCodes.size(); i += batch) { + List slice = hotelCodes.subList(i, Math.min(i + batch, hotelCodes.size())); + List part = hotelMapper.selectList(Wrappers.lambdaQuery() + .select(CanteenHotelEntity::getId, CanteenHotelEntity::getHotelCode, + CanteenHotelEntity::getHotelName) + .in(CanteenHotelEntity::getHotelCode, slice)); + if (CollUtil.isNotEmpty(part)) { + hotels.addAll(part); + } + } + int target = hotels.size(); + int missing = hotelCodes.size() - target; + int ok = 0; + int fail = 0; + int skip = missing; + int processed = 0; + List failLines = new ArrayList<>(); + int intervalMs = TYPE_ROLL.equals(syncType) + ? Math.max(0, properties.getPriceRollIntervalMs()) + : Math.max(0, properties.getDetailPullIntervalMs()); + LocalDate day = rollDay != null ? rollDay : LocalDate.now().plusDays(Math.max(1, properties.getPriceRollDayOffset())); + LocalDate end = day.plusDays(1); + + progress(logId, 8, "RETRY", "开始重试 " + target + " 家" + + (missing > 0 ? "(明细中有 " + missing + " 家本地未匹配)" : "") + + (TYPE_ROLL.equals(syncType) ? " day=" + day : "") + "…"); + + for (CanteenHotelEntity h : hotels) { + processed++; + if (StrUtil.isBlank(h.getHotelCode())) { + skip++; + continue; + } + try { + R> r; + if (TYPE_ROLL.equals(syncType)) { + r = midService.pullPriceRange(h.getId(), day, end); + } + else { + r = midService.pullFromUpstream(h.getId()); + } + if (r != null && r.getCode() == 0) { + ok++; + } + else { + fail++; + appendFail(failLines, h, r == null ? "返回为空" : StrUtil.blankToDefault(r.getMsg(), "业务失败")); + } + } + catch (Exception e) { + fail++; + appendFail(failLines, h, e.getMessage()); + } + int pct = target <= 0 ? 50 : Math.min(95, 8 + (int) (processed * 87L / target)); + progress(logId, pct, "RETRY", "重试中 " + processed + "/" + target + " 成功 " + ok + " 失败 " + fail + + " 当前 " + StrUtil.blankToDefault(h.getHotelName(), h.getHotelCode())); + if (intervalMs > 0) { + try { + Thread.sleep(intervalMs); + } + catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + failLog(logId, "任务被中断", System.currentTimeMillis() - t0); + return; + } + } + } + + CanteenHotelSyncLogEntity done = syncLogMapper.selectById(logId); + if (done == null) { + return; + } + done.setStatus(STATUS_SUCCESS); + done.setStage("DONE"); + done.setStageMsg("失败重试完成" + (TYPE_ROLL.equals(syncType) ? " day=" + day : "")); + done.setProgressPct(100); + done.setSuccessCount(ok); + done.setFailCount(fail); + done.setSkipCount(skip); + done.setLocalCount((long) hotelCodes.size()); + applyFailDetail(done, failLines, fail); + done.setCostMs(System.currentTimeMillis() - t0); + done.setFinishTime(LocalDateTime.now()); + done.setUpdateTime(LocalDateTime.now()); + syncLogMapper.updateById(done); + log.info("|失败重试|完成|logId={}|type={}|ok={}|fail={}|skip={}|", logId, syncType, ok, fail, skip); + } + catch (Exception e) { + log.error("|失败重试|失败|logId={}|", logId, e); + failLog(logId, e.getMessage(), System.currentTimeMillis() - t0); + } + } + + /** 从 failDetail 解析 hotelCode(每行 code|name|reason) */ + private static List parseFailHotelCodes(String failDetail) { + List codes = new ArrayList<>(); + if (StrUtil.isBlank(failDetail)) { + return codes; + } + java.util.LinkedHashSet uniq = new java.util.LinkedHashSet<>(); + for (String line : StrUtil.splitTrim(failDetail, '\n')) { + if (StrUtil.isBlank(line) || line.startsWith("…") || line.startsWith("...")) { + continue; + } + String code = StrUtil.subBefore(line, "|", false); + if (StrUtil.isNotBlank(code) && !code.contains(" ")) { + uniq.add(code.trim()); + } + } + codes.addAll(uniq); + return codes; + } + + /** 从滚价日志文案解析日期;解析不到则用配置偏移 */ + private LocalDate resolveRollDay(CanteenHotelSyncLogEntity src) { + String msg = StrUtil.blankToDefault(src.getStageMsg(), "") + " " + StrUtil.blankToDefault(src.getErrorMsg(), ""); + java.util.regex.Matcher m = java.util.regex.Pattern.compile("day=(\\d{4}-\\d{2}-\\d{2})").matcher(msg); + if (m.find()) { + try { + return LocalDate.parse(m.group(1)); + } + catch (Exception ignored) { + // fall through + } + } + return LocalDate.now().plusDays(Math.max(1, properties.getPriceRollDayOffset())); + } + private static void appendFail(List failLines, CanteenHotelEntity h, String reason) { if (failLines == null) { return; } - // 明细最多保留 500 行,避免撑爆字段 - if (failLines.size() >= 500) { + // 明细最多保留 2000 行,保证失败重试能覆盖大部分码 + if (failLines.size() >= 2000) { return; } String code = StrUtil.blankToDefault(h.getHotelCode(), String.valueOf(h.getId())); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml index c181c0c5b..9844e9ffe 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml @@ -30,100 +30,104 @@ security: default-encrypt-type: AES # 可选:AES、SM4,默认 AES aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位 -# ========== 玉编电云 / 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 - # ----- 日更 / 差集 ----- - # 酒店列表日同步开关(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: 5000 - # 每日滚第 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 +## ========== 玉编电云 / 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: 5000 +# # 每日滚第 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 From 8d62425d6869d3a728dfc89377da4b2042e2dd34 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Wed, 30 Sep 2026 11:53:08 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E9=85=92=E5=BA=97=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=E8=B6=85=E6=97=B6=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canteen/vo/CanteenHotelDetailVO.java | 28 -- .../controller/CanteenHotelController.java | 100 ------- .../CanteenHotelCallbackTaskMapper.java | 9 - .../CanteenHotelDownstreamAppMapper.java | 9 - .../canteen/mapper/CanteenHotelMapper.java | 14 - .../mapper/CanteenHotelOrderMapper.java | 9 - .../mapper/CanteenHotelPriceConfigMapper.java | 9 - .../mapper/CanteenHotelSyncLogMapper.java | 9 - .../CanteenHotelPriceConfigService.java | 19 -- .../canteen/service/CanteenHotelService.java | 26 -- .../src/main/resources/application.yml | 102 -------- .../entity/SmHotelCallbackTaskEntity.java | 4 +- .../entity/SmHotelDailyEntity.java | 4 +- .../entity/SmHotelDownstreamAppEntity.java | 4 +- .../supermarket/entity/SmHotelEntity.java | 4 +- .../entity/SmHotelOrderEntity.java | 4 +- .../entity/SmHotelPriceConfigEntity.java | 4 +- .../entity/SmHotelProductEntity.java | 4 +- .../supermarket/entity/SmHotelRoomEntity.java | 4 +- .../entity/SmHotelSyncLogEntity.java | 4 +- .../supermarket/vo/SmHotelDetailVO.java | 28 ++ .../supermarket/vo/SmHotelImportResultVO.java | 4 +- .../pjcloud-supermarket-biz/pom.xml | 5 + .../config/HotelDownstreamProperties.java | 2 +- .../config/ShangyouHotelProperties.java | 2 +- .../config/SupermarketSchedulingConfig.java | 4 +- .../SmHotelCallbackTaskController.java | 24 +- .../controller/SmHotelController.java | 100 +++++++ .../SmHotelDownstreamOrderController.java | 10 +- .../controller/SmHotelMidController.java | 86 +++--- .../controller/SmHotelOrderController.java | 28 +- .../controller/SmHotelUpstreamController.java | 28 +- .../downstream/DownstreamHotelApi.java | 2 +- .../downstream/DownstreamHotelClient.java | 4 +- .../downstream/DownstreamInboundAuth.java | 16 +- .../downstream/DownstreamSignHelper.java | 2 +- .../shangyou/ShangyouBookStatus.java | 2 +- .../shangyou/ShangyouCallbackQueueWorker.java | 8 +- .../shangyou/ShangyouCallbackSignHelper.java | 2 +- .../shangyou/ShangyouHotelApi.java | 28 +- .../shangyou/ShangyouHotelClient.java | 8 +- .../shangyou/ShangyouOrderCallResult.java | 2 +- .../ShangyouOrderCompensateWorker.java | 8 +- .../job/SmHotelShangyouSpringSchedule.java | 10 +- .../job/SmHotelShangyouSyncJob.java | 20 +- .../mapper/SmHotelCallbackTaskMapper.java | 9 + .../mapper/SmHotelDailyMapper.java | 8 +- .../mapper/SmHotelDownstreamAppMapper.java | 9 + .../supermarket/mapper/SmHotelMapper.java | 14 + .../mapper/SmHotelOrderMapper.java | 9 + .../mapper/SmHotelPriceConfigMapper.java | 9 + .../mapper/SmHotelProductMapper.java | 8 +- .../supermarket/mapper/SmHotelRoomMapper.java | 8 +- .../mapper/SmHotelSyncLogMapper.java | 9 + .../service/SmHotelCallbackTaskService.java | 12 +- .../service/SmHotelMidService.java | 12 +- .../service/SmHotelOrderService.java | 10 +- .../service/SmHotelPriceConfigService.java | 19 ++ .../supermarket/service/SmHotelService.java | 26 ++ .../service/SmHotelSyncService.java | 18 +- .../service/SmHotelUpstreamService.java | 4 +- .../impl/SmHotelCallbackTaskServiceImpl.java | 88 +++---- .../service/impl/SmHotelMidServiceImpl.java | 244 +++++++++--------- .../service/impl/SmHotelOrderServiceImpl.java | 180 ++++++------- .../impl/SmHotelPriceConfigServiceImpl.java | 26 +- .../service/impl/SmHotelServiceImpl.java | 98 +++---- .../service/impl/SmHotelSyncServiceImpl.java | 190 +++++++------- .../impl/SmHotelUpstreamServiceImpl.java | 22 +- .../src/main/resources/application.yml | 102 ++++++++ .../resources/mapper/SmHotelDailyMapper.xml | 2 +- .../main/resources/mapper/SmHotelMapper.xml | 2 +- .../resources/mapper/SmHotelProductMapper.xml | 2 +- .../resources/mapper/SmHotelRoomMapper.xml | 2 +- 73 files changed, 975 insertions(+), 970 deletions(-) delete mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelDetailVO.java delete mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java delete mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelCallbackTaskMapper.java delete mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDownstreamAppMapper.java delete mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelMapper.java delete mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelOrderMapper.java delete mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelPriceConfigMapper.java delete mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelSyncLogMapper.java delete mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelPriceConfigService.java delete mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelService.java rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelCallbackTaskEntity.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelCallbackTaskEntity.java (92%) rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDailyEntity.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDailyEntity.java (91%) rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDownstreamAppEntity.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamAppEntity.java (88%) rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java (94%) rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelOrderEntity.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java (95%) rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelPriceConfigEntity.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceConfigEntity.java (89%) rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelProductEntity.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelProductEntity.java (92%) rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelRoomEntity.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelRoomEntity.java (90%) rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelSyncLogEntity.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelSyncLogEntity.java (93%) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelDetailVO.java rename pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelImportResultVO.java => pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelImportResultVO.java (93%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/config/HotelDownstreamProperties.java (95%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/config/ShangyouHotelProperties.java (99%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/CanteenSchedulingConfig.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/SupermarketSchedulingConfig.java (75%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelCallbackTaskController.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java (60%) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelDownstreamOrderController.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelDownstreamOrderController.java (88%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java (69%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelOrderController.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java (67%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelUpstreamController.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelUpstreamController.java (83%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/downstream/DownstreamHotelApi.java (88%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/downstream/DownstreamHotelClient.java (96%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/downstream/DownstreamInboundAuth.java (73%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/downstream/DownstreamSignHelper.java (94%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/shangyou/ShangyouBookStatus.java (95%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/shangyou/ShangyouCallbackQueueWorker.java (92%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/shangyou/ShangyouCallbackSignHelper.java (89%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/shangyou/ShangyouHotelApi.java (52%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/shangyou/ShangyouHotelClient.java (99%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/shangyou/ShangyouOrderCallResult.java (97%) rename {pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket}/integration/shangyou/ShangyouOrderCompensateWorker.java (89%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSpringSchedule.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSpringSchedule.java (84%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSyncJob.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSyncJob.java (66%) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelCallbackTaskMapper.java rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java (79%) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDownstreamAppMapper.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelMapper.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelOrderMapper.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelPriceConfigMapper.java rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelProductMapper.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelProductMapper.java (70%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelRoomMapper.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelRoomMapper.java (70%) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelSyncLogMapper.java rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelCallbackTaskService.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCallbackTaskService.java (69%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelMidService.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java (79%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelOrderService.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java (79%) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelPriceConfigService.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelService.java rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelSyncService.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelSyncService.java (70%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelUpstreamService.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelUpstreamService.java (93%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelCallbackTaskServiceImpl.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelCallbackTaskServiceImpl.java (74%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelMidServiceImpl.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java (78%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelOrderServiceImpl.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java (83%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelPriceConfigServiceImpl.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelPriceConfigServiceImpl.java (60%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelServiceImpl.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java (83%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelSyncServiceImpl.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java (82%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenHotelUpstreamServiceImpl.java => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelUpstreamServiceImpl.java (90%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelDailyMapper.xml => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmHotelDailyMapper.xml (90%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelMapper.xml => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmHotelMapper.xml (91%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelProductMapper.xml => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmHotelProductMapper.xml (93%) rename pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenHotelRoomMapper.xml => pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/mapper/SmHotelRoomMapper.xml (91%) diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelDetailVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelDetailVO.java deleted file mode 100644 index 149513062..000000000 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelDetailVO.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.bsd.pjcloud.canteen.vo; - -import com.bsd.pjcloud.canteen.entity.CanteenHotelDailyEntity; -import com.bsd.pjcloud.canteen.entity.CanteenHotelEntity; -import com.bsd.pjcloud.canteen.entity.CanteenHotelProductEntity; -import com.bsd.pjcloud.canteen.entity.CanteenHotelRoomEntity; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.util.ArrayList; -import java.util.List; - -@Data -@Schema(description = "酒店详情(含上游拉取的房型/产品摘要)") -public class CanteenHotelDetailVO { - - private CanteenHotelEntity hotel; - - private List rooms = new ArrayList<>(); - - private List products = new ArrayList<>(); - - @Schema(description = "日历条数(未来含今天)") - private Long dailyCount; - - @Schema(description = "日历预览(默认最近一段,完整日历走分页接口)") - private List dailyPreview = new ArrayList<>(); -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java deleted file mode 100644 index bc24da9e4..000000000 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelController.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.bsd.pjcloud.canteen.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.canteen.entity.CanteenHotelEntity; -import com.bsd.pjcloud.canteen.service.CanteenHotelService; -import com.bsd.pjcloud.canteen.vo.CanteenHotelImportResultVO; -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("/canteenHotel") -@Tag(name = "酒店管理") -@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) -public class CanteenHotelController { - - private final CanteenHotelService hotelService; - - @Operation(summary = "分页查询") - @GetMapping("/page") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") - public R> page(@ParameterObject Page page, - @ParameterObject CanteenHotelEntity query) { - var wrapper = Wrappers.lambdaQuery() - .like(StrUtil.isNotBlank(query.getHotelName()), CanteenHotelEntity::getHotelName, query.getHotelName()) - .like(StrUtil.isNotBlank(query.getHotelCode()), CanteenHotelEntity::getHotelCode, query.getHotelCode()) - .like(StrUtil.isNotBlank(query.getGroupCode()), CanteenHotelEntity::getGroupCode, query.getGroupCode()) - .eq(StrUtil.isNotBlank(query.getSourceId()), CanteenHotelEntity::getSourceId, query.getSourceId()) - .eq(StrUtil.isNotBlank(query.getSheetName()), CanteenHotelEntity::getSheetName, query.getSheetName()) - .orderByDesc(CanteenHotelEntity::getCreateTime) - .orderByDesc(CanteenHotelEntity::getId); - // 启用:含未写状态的老数据;停用:仅 OFFLINE;空=全部 - if ("OFFLINE".equalsIgnoreCase(query.getUpstreamStatus())) { - wrapper.eq(CanteenHotelEntity::getUpstreamStatus, "OFFLINE"); - } - else if ("ACTIVE".equalsIgnoreCase(query.getUpstreamStatus())) { - wrapper.and(w -> w.isNull(CanteenHotelEntity::getUpstreamStatus) - .or() - .ne(CanteenHotelEntity::getUpstreamStatus, "OFFLINE")); - } - return R.ok(hotelService.pageScope(page, wrapper)); - } - - /** 上传文件会立刻 transferTo 临时目录,勿加 @SysLog */ - @Operation(summary = "提交Excel异步导入(多Sheet流式)") - @PostMapping("/import") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_import')") - public R importExcel(@RequestParam("file") MultipartFile file) { - return hotelService.startImport(file); - } - - @Operation(summary = "查询酒店导入进度") - @GetMapping("/import/status/{taskId}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_import')") - public R importStatus(@PathVariable String taskId) { - return hotelService.getImportStatus(taskId); - } - - @Operation(summary = "详情") - @GetMapping("/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") - public R getById(@PathVariable Long id) { - return R.ok(hotelService.getById(id)); - } - - @SysLog("新增酒店") - @Operation(summary = "新增") - @PostMapping - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_add')") - public R save(@RequestBody CanteenHotelEntity entity) { - return R.ok(hotelService.saveHotel(entity)); - } - - @SysLog("修改酒店") - @Operation(summary = "修改") - @PutMapping - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") - public R update(@RequestBody CanteenHotelEntity entity) { - return R.ok(hotelService.updateHotel(entity)); - } - - @SysLog("删除酒店") - @Operation(summary = "删除") - @DeleteMapping - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_del')") - public R remove(@RequestBody Long[] ids) { - return R.ok(hotelService.removeHotels(ids)); - } -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelCallbackTaskMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelCallbackTaskMapper.java deleted file mode 100644 index 3c6f0ad03..000000000 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelCallbackTaskMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.bsd.pjcloud.canteen.mapper; - -import com.bsd.pjcloud.canteen.entity.CanteenHotelCallbackTaskEntity; -import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface CanteenHotelCallbackTaskMapper extends PjcloudBaseMapper { -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDownstreamAppMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDownstreamAppMapper.java deleted file mode 100644 index dcf0d1ed3..000000000 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDownstreamAppMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.bsd.pjcloud.canteen.mapper; - -import com.bsd.pjcloud.canteen.entity.CanteenHotelDownstreamAppEntity; -import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface CanteenHotelDownstreamAppMapper extends PjcloudBaseMapper { -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelMapper.java deleted file mode 100644 index 6cf0a0d10..000000000 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.bsd.pjcloud.canteen.mapper; - -import com.bsd.pjcloud.canteen.entity.CanteenHotelEntity; -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 CanteenHotelMapper extends PjcloudBaseMapper { - - int insertBatch(@Param("list") List list); -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelOrderMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelOrderMapper.java deleted file mode 100644 index 0f2a4ae30..000000000 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelOrderMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.bsd.pjcloud.canteen.mapper; - -import com.bsd.pjcloud.canteen.entity.CanteenHotelOrderEntity; -import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface CanteenHotelOrderMapper extends PjcloudBaseMapper { -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelPriceConfigMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelPriceConfigMapper.java deleted file mode 100644 index 6fd9c404e..000000000 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelPriceConfigMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.bsd.pjcloud.canteen.mapper; - -import com.bsd.pjcloud.canteen.entity.CanteenHotelPriceConfigEntity; -import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface CanteenHotelPriceConfigMapper extends PjcloudBaseMapper { -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelSyncLogMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelSyncLogMapper.java deleted file mode 100644 index 090be7088..000000000 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelSyncLogMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.bsd.pjcloud.canteen.mapper; - -import com.bsd.pjcloud.canteen.entity.CanteenHotelSyncLogEntity; -import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface CanteenHotelSyncLogMapper extends PjcloudBaseMapper { -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelPriceConfigService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelPriceConfigService.java deleted file mode 100644 index 0c9f57797..000000000 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelPriceConfigService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.bsd.pjcloud.canteen.service; - -import com.bsd.pjcloud.canteen.entity.CanteenHotelPriceConfigEntity; -import com.bsd.pjcloud.common.core.util.R; - -import java.math.BigDecimal; - -public interface CanteenHotelPriceConfigService { - - R getCurrent(); - - R saveOrUpdateConfig(CanteenHotelPriceConfigEntity entity); - - /** 当前加价百分比,未配置返回 0 */ - BigDecimal currentMarkupPercent(); - - /** 底价按加价百分比计算卖价 */ - BigDecimal applyMarkup(BigDecimal costPrice); -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelService.java deleted file mode 100644 index 80a28693e..000000000 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenHotelService.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.bsd.pjcloud.canteen.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.canteen.entity.CanteenHotelEntity; -import com.bsd.pjcloud.canteen.vo.CanteenHotelImportResultVO; -import com.bsd.pjcloud.common.core.util.R; -import org.springframework.web.multipart.MultipartFile; - -public interface CanteenHotelService extends IService { - - Page pageScope(Page page, LambdaQueryWrapper wrapper); - - Boolean saveHotel(CanteenHotelEntity entity); - - Boolean updateHotel(CanteenHotelEntity entity); - - Boolean removeHotels(Long[] ids); - - /** 提交异步导入,立刻返回 taskId(status=RUNNING) */ - R startImport(MultipartFile file); - - /** 查询导入任务进度 */ - R getImportStatus(String taskId); -} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml index 9844e9ffe..f0c7c67fa 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/application.yml @@ -29,105 +29,3 @@ security: enable: true # 开启请求加解密 default-encrypt-type: AES # 可选:AES、SM4,默认 AES aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位 - -## ========== 玉编电云 / 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: 5000 -# # 每日滚第 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 diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelCallbackTaskEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelCallbackTaskEntity.java similarity index 92% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelCallbackTaskEntity.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelCallbackTaskEntity.java index a103d2b62..2c8814bfe 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelCallbackTaskEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelCallbackTaskEntity.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.entity; +package com.bsd.pjcloud.supermarket.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; @@ -14,7 +14,7 @@ import java.time.LocalDateTime; @TableName("canteen_hotel_callback_task") @EqualsAndHashCode(callSuper = true) @Schema(description = "上游酒店回调异步任务") -public class CanteenHotelCallbackTaskEntity extends Model { +public class SmHotelCallbackTaskEntity extends Model { @TableId(type = IdType.ASSIGN_ID) private Long id; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDailyEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDailyEntity.java similarity index 91% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDailyEntity.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDailyEntity.java index 8a55dbd92..f0c12e5da 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDailyEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDailyEntity.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.entity; +package com.bsd.pjcloud.supermarket.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; @@ -16,7 +16,7 @@ import java.time.LocalDateTime; @TableName("canteen_hotel_daily") @EqualsAndHashCode(callSuper = true) @Schema(description = "酒店日历房") -public class CanteenHotelDailyEntity extends Model { +public class SmHotelDailyEntity extends Model { @TableId(type = IdType.ASSIGN_ID) private Long id; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDownstreamAppEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamAppEntity.java similarity index 88% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDownstreamAppEntity.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamAppEntity.java index fe7615dd5..32999848c 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelDownstreamAppEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamAppEntity.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.entity; +package com.bsd.pjcloud.supermarket.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; @@ -14,7 +14,7 @@ import java.time.LocalDateTime; @TableName("canteen_hotel_downstream_app") @EqualsAndHashCode(callSuper = true) @Schema(description = "下游销售应用") -public class CanteenHotelDownstreamAppEntity extends Model { +public class SmHotelDownstreamAppEntity extends Model { @TableId(type = IdType.ASSIGN_ID) private Long id; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java similarity index 94% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java index 42e0b9236..197bbb4d2 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.entity; +package com.bsd.pjcloud.supermarket.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; @@ -14,7 +14,7 @@ import java.time.LocalDateTime; @TableName("canteen_hotel") @EqualsAndHashCode(callSuper = true) @Schema(description = "酒店管理") -public class CanteenHotelEntity extends Model { +public class SmHotelEntity extends Model { @TableId(type = IdType.ASSIGN_ID) @Schema(description = "主键(系统生成)") diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelOrderEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java similarity index 95% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelOrderEntity.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java index 8f1c5d075..db3917450 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelOrderEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.entity; +package com.bsd.pjcloud.supermarket.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; @@ -16,7 +16,7 @@ import java.time.LocalDateTime; @TableName("canteen_hotel_order") @EqualsAndHashCode(callSuper = true) @Schema(description = "酒店中转订单") -public class CanteenHotelOrderEntity extends Model { +public class SmHotelOrderEntity extends Model { @TableId(type = IdType.ASSIGN_ID) private Long id; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelPriceConfigEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceConfigEntity.java similarity index 89% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelPriceConfigEntity.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceConfigEntity.java index 72a54c415..bdbf11533 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelPriceConfigEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelPriceConfigEntity.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.entity; +package com.bsd.pjcloud.supermarket.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; @@ -15,7 +15,7 @@ import java.time.LocalDateTime; @TableName("canteen_hotel_price_config") @EqualsAndHashCode(callSuper = true) @Schema(description = "酒店全局加价配置") -public class CanteenHotelPriceConfigEntity extends Model { +public class SmHotelPriceConfigEntity extends Model { @TableId(type = IdType.ASSIGN_ID) private Long id; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelProductEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelProductEntity.java similarity index 92% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelProductEntity.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelProductEntity.java index 7bf87bb0c..c6d5854b4 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelProductEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelProductEntity.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.entity; +package com.bsd.pjcloud.supermarket.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; @@ -15,7 +15,7 @@ import java.time.LocalDateTime; @TableName("canteen_hotel_product") @EqualsAndHashCode(callSuper = true) @Schema(description = "酒店产品") -public class CanteenHotelProductEntity extends Model { +public class SmHotelProductEntity extends Model { @TableId(type = IdType.ASSIGN_ID) private Long id; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelRoomEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelRoomEntity.java similarity index 90% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelRoomEntity.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelRoomEntity.java index d6c3bb0bb..c38f053d4 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelRoomEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelRoomEntity.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.entity; +package com.bsd.pjcloud.supermarket.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; @@ -14,7 +14,7 @@ import java.time.LocalDateTime; @TableName("canteen_hotel_room") @EqualsAndHashCode(callSuper = true) @Schema(description = "酒店房型") -public class CanteenHotelRoomEntity extends Model { +public class SmHotelRoomEntity extends Model { @TableId(type = IdType.ASSIGN_ID) private Long id; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelSyncLogEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelSyncLogEntity.java similarity index 93% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelSyncLogEntity.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelSyncLogEntity.java index c891971f8..11768532c 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenHotelSyncLogEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelSyncLogEntity.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.entity; +package com.bsd.pjcloud.supermarket.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; @@ -14,7 +14,7 @@ import java.time.LocalDateTime; @TableName("canteen_hotel_sync_log") @EqualsAndHashCode(callSuper = true) @Schema(description = "酒店上游同步日志") -public class CanteenHotelSyncLogEntity extends Model { +public class SmHotelSyncLogEntity extends Model { @TableId(type = IdType.ASSIGN_ID) private Long id; diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelDetailVO.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelDetailVO.java new file mode 100644 index 000000000..b20fb6261 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelDetailVO.java @@ -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 rooms = new ArrayList<>(); + + private List products = new ArrayList<>(); + + @Schema(description = "日历条数(未来含今天)") + private Long dailyCount; + + @Schema(description = "日历预览(默认最近一段,完整日历走分页接口)") + private List dailyPreview = new ArrayList<>(); +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelImportResultVO.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelImportResultVO.java similarity index 93% rename from pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelImportResultVO.java rename to pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelImportResultVO.java index 669f061de..5034e008d 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenHotelImportResultVO.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelImportResultVO.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.vo; +package com.bsd.pjcloud.supermarket.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -8,7 +8,7 @@ import java.util.List; @Data @Schema(description = "酒店Excel导入任务/结果") -public class CanteenHotelImportResultVO { +public class SmHotelImportResultVO { @Schema(description = "任务ID(异步导入)") private String taskId; diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml b/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml index bc1d8f346..8cdfd5cc0 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/pom.xml @@ -127,6 +127,11 @@ pjcloud-common-sequence 5.7.0 + + + com.bsd + pjcloud-common-job + diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/HotelDownstreamProperties.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java similarity index 95% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/HotelDownstreamProperties.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java index 8ebc8e5dd..fcbb37ad5 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/HotelDownstreamProperties.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.config; +package com.bsd.pjcloud.supermarket.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/ShangyouHotelProperties.java similarity index 99% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/ShangyouHotelProperties.java index 3da1d31ae..e1a275bed 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/ShangyouHotelProperties.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/ShangyouHotelProperties.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.config; +package com.bsd.pjcloud.supermarket.config; import cn.hutool.core.util.StrUtil; import lombok.Data; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/CanteenSchedulingConfig.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/SupermarketSchedulingConfig.java similarity index 75% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/CanteenSchedulingConfig.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/SupermarketSchedulingConfig.java index b2e4058f6..e9cba7964 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/config/CanteenSchedulingConfig.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/SupermarketSchedulingConfig.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.config; +package com.bsd.pjcloud.supermarket.config; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; @@ -8,5 +8,5 @@ import org.springframework.scheduling.annotation.EnableScheduling; */ @Configuration @EnableScheduling -public class CanteenSchedulingConfig { +public class SupermarketSchedulingConfig { } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelCallbackTaskController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java similarity index 60% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelCallbackTaskController.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java index 340e6c675..a4c761a4f 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelCallbackTaskController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java @@ -1,8 +1,8 @@ -package com.bsd.pjcloud.canteen.controller; +package com.bsd.pjcloud.supermarket.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.bsd.pjcloud.canteen.entity.CanteenHotelCallbackTaskEntity; -import com.bsd.pjcloud.canteen.service.CanteenHotelCallbackTaskService; +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; @@ -19,32 +19,32 @@ import org.springframework.web.bind.annotation.*; */ @RestController @RequiredArgsConstructor -@RequestMapping("/canteenHotelCallback") +@RequestMapping("/smHotelCallback") @Tag(name = "酒店上游回调任务") @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) -public class CanteenHotelCallbackTaskController { +public class SmHotelCallbackTaskController { - private final CanteenHotelCallbackTaskService callbackTaskService; + private final SmHotelCallbackTaskService callbackTaskService; @Operation(summary = "回调任务分页") @GetMapping("/page") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotelCallback_view')") - public R> page(@ParameterObject Page page, - @ParameterObject CanteenHotelCallbackTaskEntity query) { + @PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_view')") + public R> page(@ParameterObject Page page, + @ParameterObject SmHotelCallbackTaskEntity query) { return R.ok(callbackTaskService.pageTasks(page, query)); } @Operation(summary = "回调任务详情") @GetMapping("/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotelCallback_view')") - public R detail(@PathVariable Long id) { + @PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_view')") + public R detail(@PathVariable Long id) { return R.ok(callbackTaskService.getDetail(id)); } @SysLog("酒店回调任务重试") @Operation(summary = "失败/跳过任务重新入队") @PostMapping("/retry/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotelCallback_retry')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_retry')") public R retry(@PathVariable Long id) { return callbackTaskService.retry(id); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java new file mode 100644 index 000000000..b054fa850 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java @@ -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(@ParameterObject Page page, + @ParameterObject SmHotelEntity query) { + var wrapper = Wrappers.lambdaQuery() + .like(StrUtil.isNotBlank(query.getHotelName()), SmHotelEntity::getHotelName, query.getHotelName()) + .like(StrUtil.isNotBlank(query.getHotelCode()), SmHotelEntity::getHotelCode, query.getHotelCode()) + .like(StrUtil.isNotBlank(query.getGroupCode()), SmHotelEntity::getGroupCode, query.getGroupCode()) + .eq(StrUtil.isNotBlank(query.getSourceId()), SmHotelEntity::getSourceId, query.getSourceId()) + .eq(StrUtil.isNotBlank(query.getSheetName()), SmHotelEntity::getSheetName, query.getSheetName()) + .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 importExcel(@RequestParam("file") MultipartFile file) { + return hotelService.startImport(file); + } + + @Operation(summary = "查询酒店导入进度") + @GetMapping("/import/status/{taskId}") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_import')") + public R 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)); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelDownstreamOrderController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelDownstreamOrderController.java similarity index 88% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelDownstreamOrderController.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelDownstreamOrderController.java index 6981886ca..135e54c6e 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelDownstreamOrderController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelDownstreamOrderController.java @@ -1,9 +1,9 @@ -package com.bsd.pjcloud.canteen.controller; +package com.bsd.pjcloud.supermarket.controller; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; -import com.bsd.pjcloud.canteen.integration.downstream.DownstreamInboundAuth; -import com.bsd.pjcloud.canteen.service.CanteenHotelOrderService; +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; @@ -22,11 +22,11 @@ import java.util.Map; @RequiredArgsConstructor @RequestMapping("/third/party/hotel/order") @Tag(name = "下游酒店订单回传") -public class CanteenHotelDownstreamOrderController { +public class SmHotelDownstreamOrderController { private final DownstreamInboundAuth inboundAuth; - private final CanteenHotelOrderService orderService; + private final SmHotelOrderService orderService; @Inner(value = false) @Operation(summary = "下游下单回传(我方再调上游创建)") diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java similarity index 69% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java index a85f91cae..b64ced59d 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelMidController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java @@ -1,13 +1,13 @@ -package com.bsd.pjcloud.canteen.controller; +package com.bsd.pjcloud.supermarket.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.bsd.pjcloud.canteen.entity.CanteenHotelDailyEntity; -import com.bsd.pjcloud.canteen.entity.CanteenHotelPriceConfigEntity; -import com.bsd.pjcloud.canteen.entity.CanteenHotelSyncLogEntity; -import com.bsd.pjcloud.canteen.service.CanteenHotelMidService; -import com.bsd.pjcloud.canteen.service.CanteenHotelPriceConfigService; -import com.bsd.pjcloud.canteen.service.CanteenHotelSyncService; -import com.bsd.pjcloud.canteen.vo.CanteenHotelDetailVO; +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; @@ -25,79 +25,79 @@ import java.util.Map; @RestController @RequiredArgsConstructor -@RequestMapping("/canteenHotel") +@RequestMapping("/smHotel") @Tag(name = "酒店中转(拉取/推送/加价)") @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) -public class CanteenHotelMidController { +public class SmHotelMidController { - private final CanteenHotelMidService midService; + private final SmHotelMidService midService; - private final CanteenHotelPriceConfigService priceConfigService; + private final SmHotelPriceConfigService priceConfigService; - private final CanteenHotelSyncService syncService; + private final SmHotelSyncService syncService; @Operation(summary = "查询全局加价配置") @GetMapping("/priceConfig") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotelPrice_view')") - public R getPriceConfig() { + @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')") + public R getPriceConfig() { return priceConfigService.getCurrent(); } @SysLog("保存酒店加价配置") @Operation(summary = "保存全局加价配置") @PutMapping("/priceConfig") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotelPrice_edit')") - public R savePriceConfig(@RequestBody CanteenHotelPriceConfigEntity entity) { + @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')") + public R savePriceConfig(@RequestBody SmHotelPriceConfigEntity entity) { return priceConfigService.saveOrUpdateConfig(entity); } @Operation(summary = "酒店详情(含已拉取房型/产品/日历预览)") @GetMapping("/detail/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") - public R detail(@PathVariable Long id) { + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") + public R detail(@PathVariable Long id) { return midService.getHotelDetail(id); } @Operation(summary = "酒店日历分页") @GetMapping("/detail/{id}/daily") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") - public R> dailyPage(@PathVariable Long id, - @ParameterObject Page page, + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") + public R> dailyPage(@PathVariable Long id, + @ParameterObject Page page, @RequestParam(required = false) String productCode) { return midService.pageDaily(id, page, productCode); } @Operation(summary = "从上游拉取房型产品并加价落库") @PostMapping("/mid/pull/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R> pull(@PathVariable Long id) { return midService.pullFromUpstream(id); } @Operation(summary = "推送到下游销售方") @PostMapping("/mid/push/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_push')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_push')") public R> push(@PathVariable Long id) { return midService.pushToDownstream(id); } @Operation(summary = "拉取上游并推送下游") @PostMapping("/mid/pullAndPush/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_push')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_push')") public R> pullAndPush(@PathVariable Long id) { return midService.pullAndPush(id); } @Operation(summary = "批量拉取并推送") @PostMapping("/mid/pullAndPush/batch") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_push')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_push')") public R> pullAndPushBatch(@RequestBody List ids) { return midService.pullAndPushBatch(ids); } @Operation(summary = "查询上游账号已授权酒店列表") @GetMapping("/mid/authorizedHotels") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R> authorizedHotels(@RequestParam(required = false) String keyword, @RequestParam(required = false, defaultValue = "1") long current, @RequestParam(required = false, defaultValue = "20") long size, @@ -108,7 +108,7 @@ public class CanteenHotelMidController { @SysLog("导入上游授权酒店到本地") @Operation(summary = "将上游授权酒店写入本地") @PostMapping("/mid/authorizedHotels/import") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_add')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_add')") public R> importAuthorizedHotels(@RequestBody List> hotels) { return midService.importAuthorizedHotels(hotels); } @@ -116,7 +116,7 @@ public class CanteenHotelMidController { @SysLog("同步上游酒店列表并差集") @Operation(summary = "异步同步 getHotelList + Redis 差集(立刻返回 logId,轮询进度)") @PostMapping("/mid/syncHotelList") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R> syncHotelList( @RequestParam(required = false, defaultValue = "true") boolean autoImport) { return syncService.syncHotelListAndDiff(autoImport); @@ -124,29 +124,29 @@ public class CanteenHotelMidController { @Operation(summary = "同步日志分页") @GetMapping("/mid/syncLog/page") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") - public R> syncLogPage(@ParameterObject Page page, - @ParameterObject CanteenHotelSyncLogEntity query) { + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") + public R> syncLogPage(@ParameterObject Page page, + @ParameterObject SmHotelSyncLogEntity query) { return R.ok(syncService.pageSyncLogs(page, query)); } @Operation(summary = "查询单次同步进度/结果") @GetMapping("/mid/syncLog/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") - public R syncLog(@PathVariable Long id) { + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") + public R syncLog(@PathVariable Long id) { return syncService.getSyncLog(id); } @Operation(summary = "日更时间配置(只读展示)") @GetMapping("/mid/syncSchedule") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R> syncSchedule() { return R.ok(syncService.getSyncSchedule()); } @Operation(summary = "分页查看酒店差集 new|offline") @GetMapping("/mid/hotelDiff") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R> hotelDiff(@RequestParam(required = false, defaultValue = "new") String type, @RequestParam(required = false, defaultValue = "1") long current, @RequestParam(required = false, defaultValue = "20") long size) { @@ -156,7 +156,7 @@ public class CanteenHotelMidController { @SysLog("导入差集新增酒店") @Operation(summary = "将差集中的新增酒店导入本地") @PostMapping("/mid/hotelDiff/importNew") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_add')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_add')") public R> importDiffNew(@RequestParam(required = false) Integer limit) { return syncService.importDiffNewHotels(limit); } @@ -164,9 +164,9 @@ public class CanteenHotelMidController { @SysLog("手动执行滚一天房价") @Operation(summary = "异步滚一天房价(立刻返回 logId)") @PostMapping("/mid/rollDailyPrice") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R> rollDailyPrice() { - R started = syncService.startPriceRollAsync("MANUAL"); + R started = syncService.startPriceRollAsync("MANUAL"); if (started.getCode() != 0) { return R.failed(started.getMsg()); } @@ -180,10 +180,10 @@ public class CanteenHotelMidController { @SysLog("全量拉取启用酒店详情") @Operation(summary = "异步拉取全部启用酒店详情(房型+产品+未来房价),立刻返回 logId") @PostMapping("/mid/pullAllActiveDetails") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R> pullAllActiveDetails( @RequestParam(required = false) Integer limit) { - R started = syncService.startDetailPullAsync(limit, "MANUAL"); + R started = syncService.startDetailPullAsync(limit, "MANUAL"); if (started.getCode() != 0) { return R.failed(started.getMsg()); } @@ -198,9 +198,9 @@ public class CanteenHotelMidController { @SysLog("重试同步失败酒店") @Operation(summary = "根据某次同步日志的失败明细,只重试失败酒店(详情/滚价)") @PostMapping("/mid/retryFailedFromLog") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R> retryFailedFromLog(@RequestParam Long logId) { - R started = syncService.startRetryFailedAsync(logId); + R started = syncService.startRetryFailedAsync(logId); if (started.getCode() != 0) { return R.failed(started.getMsg()); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelOrderController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java similarity index 67% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelOrderController.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java index 6edbcaee5..0324129d9 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelOrderController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderController.java @@ -1,8 +1,8 @@ -package com.bsd.pjcloud.canteen.controller; +package com.bsd.pjcloud.supermarket.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.bsd.pjcloud.canteen.entity.CanteenHotelOrderEntity; -import com.bsd.pjcloud.canteen.service.CanteenHotelOrderService; +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; @@ -18,32 +18,32 @@ import java.util.Map; @RestController @RequiredArgsConstructor -@RequestMapping("/canteenHotelOrder") +@RequestMapping("/smHotelOrder") @Tag(name = "酒店中转订单") @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) -public class CanteenHotelOrderController { +public class SmHotelOrderController { - private final CanteenHotelOrderService orderService; + private final SmHotelOrderService orderService; @Operation(summary = "订单分页") @GetMapping("/page") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotelOrder_view')") - public R> page(@ParameterObject Page page, - @ParameterObject CanteenHotelOrderEntity query) { + @PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')") + public R> page(@ParameterObject Page page, + @ParameterObject SmHotelOrderEntity query) { return R.ok(orderService.pageOrders(page, query)); } @Operation(summary = "订单详情") @GetMapping("/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotelOrder_view')") - public R detail(@PathVariable Long id) { + @PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')") + public R detail(@PathVariable Long id) { return R.ok(orderService.getDetail(id)); } @SysLog("后台取消酒店订单") @Operation(summary = "后台取消订单") @PostMapping("/cancel/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotelOrder_cancel')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_cancel')") public R cancel(@PathVariable Long id, @RequestBody(required = false) Map body) { String reason = body == null ? null : String.valueOf(body.getOrDefault("reason", "后台取消")); return orderService.cancelByAdmin(id, reason); @@ -52,7 +52,7 @@ public class CanteenHotelOrderController { @SysLog("后台试下单(调上游)") @Operation(summary = "后台试下单:校验可定后调上游 order/add") @PostMapping("/create") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R createByAdmin(@RequestBody Map body) { return orderService.createByAdmin(body); } @@ -60,7 +60,7 @@ public class CanteenHotelOrderController { @SysLog("酒店订单补偿查询") @Operation(summary = "立即补偿查询(queryByRequestNo)") @PostMapping("/compensate/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotelOrder_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')") public R compensate(@PathVariable Long id) { return orderService.compensateNow(id); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelUpstreamController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelUpstreamController.java similarity index 83% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelUpstreamController.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelUpstreamController.java index 880c9f332..cbf956bb0 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenHotelUpstreamController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelUpstreamController.java @@ -1,7 +1,7 @@ -package com.bsd.pjcloud.canteen.controller; +package com.bsd.pjcloud.supermarket.controller; import cn.hutool.json.JSONObject; -import com.bsd.pjcloud.canteen.service.CanteenHotelUpstreamService; +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; @@ -20,44 +20,44 @@ import java.util.Map; */ @RestController @RequiredArgsConstructor -@RequestMapping("/canteenHotel/upstream") +@RequestMapping("/smHotel/upstream") @Tag(name = "酒店上游对接") @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) -public class CanteenHotelUpstreamController { +public class SmHotelUpstreamController { - private final CanteenHotelUpstreamService upstreamService; + private final SmHotelUpstreamService upstreamService; @Operation(summary = "同步上游酒店信息到本地") @PostMapping("/sync/{id}") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R sync(@PathVariable Long id) { return upstreamService.syncHotelInfo(id); } @Operation(summary = "批量同步上游酒店信息") @PostMapping("/sync/batch") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R> syncBatch(@RequestBody List ids) { return upstreamService.syncHotelInfoBatch(ids); } @Operation(summary = "上游-酒店综合信息") @PostMapping("/hotelInfo") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R hotelInfo(@RequestBody Map body) { return upstreamService.hotelInfo(str(body.get("groupCode")), str(body.get("hotelCode"))); } @Operation(summary = "上游-房型列表") @PostMapping("/roomTypeList") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R roomTypeList(@RequestBody Map body) { return upstreamService.roomTypeList(str(body.get("groupCode")), str(body.get("hotelCode"))); } @Operation(summary = "上游-房价房量样例") @PostMapping("/productList") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R productList(@RequestBody Map body) { return upstreamService.productList(str(body.get("groupCode")), str(body.get("hotelCode")), str(body.get("channelCode"))); @@ -65,28 +65,28 @@ public class CanteenHotelUpstreamController { @Operation(summary = "上游-可定查询") @PostMapping("/order/check") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R orderCheck(@RequestBody Map body) { return upstreamService.orderCheck(body); } @Operation(summary = "上游-下单") @PostMapping("/order/create") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R orderCreate(@RequestBody Map body) { return upstreamService.orderCreate(body); } @Operation(summary = "上游-取消订单") @PostMapping("/order/cancel") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") public R orderCancel(@RequestBody Map body) { return upstreamService.orderCancel(body); } @Operation(summary = "上游-订单详情") @PostMapping("/order/getById") - @PreAuthorize("@pms.hasPermission('canteen_canteenHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") public R orderGetById(@RequestBody Map body) { return upstreamService.orderGetById(body); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelApi.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelApi.java similarity index 88% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelApi.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelApi.java index 4603779f7..bbdb546e3 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelApi.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelApi.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.integration.downstream; +package com.bsd.pjcloud.supermarket.integration.downstream; /** * 下游销售方(B系统)接收接口路径。 diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelClient.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java similarity index 96% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelClient.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java index f1b1bdef8..44e241e55 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamHotelClient.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.integration.downstream; +package com.bsd.pjcloud.supermarket.integration.downstream; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; @@ -6,7 +6,7 @@ import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; -import com.bsd.pjcloud.canteen.config.HotelDownstreamProperties; +import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties; import com.bsd.pjcloud.common.core.exception.CheckedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamInboundAuth.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java similarity index 73% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamInboundAuth.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java index a39cee90a..b94282b82 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamInboundAuth.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java @@ -1,10 +1,10 @@ -package com.bsd.pjcloud.canteen.integration.downstream; +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.canteen.entity.CanteenHotelDownstreamAppEntity; -import com.bsd.pjcloud.canteen.mapper.CanteenHotelDownstreamAppMapper; +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; @@ -21,11 +21,11 @@ public class DownstreamInboundAuth { private static final String NONCE_KEY = "canteen:hotel:downstream:nonce:"; - private final CanteenHotelDownstreamAppMapper appMapper; + private final SmHotelDownstreamAppMapper appMapper; private final StringRedisTemplate redisTemplate; - public CanteenHotelDownstreamAppEntity verify(String appKey, String timestamp, String nonce, String sign, + 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("缺少鉴权参数"); @@ -44,9 +44,9 @@ public class DownstreamInboundAuth { if (Boolean.FALSE.equals(ok)) { throw new CheckedException("nonce 重复"); } - CanteenHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.lambdaQuery() - .eq(CanteenHotelDownstreamAppEntity::getAppKey, appKey) - .eq(CanteenHotelDownstreamAppEntity::getStatus, "1") + SmHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelDownstreamAppEntity::getAppKey, appKey) + .eq(SmHotelDownstreamAppEntity::getStatus, "1") .last("limit 1")); if (app == null) { throw new CheckedException("appKey 无效"); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamSignHelper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamSignHelper.java similarity index 94% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamSignHelper.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamSignHelper.java index f8e4fbbc1..668cfe104 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/downstream/DownstreamSignHelper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamSignHelper.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.integration.downstream; +package com.bsd.pjcloud.supermarket.integration.downstream; import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.core.util.StrUtil; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouBookStatus.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouBookStatus.java similarity index 95% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouBookStatus.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouBookStatus.java index f4bcf3267..757f9a849 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouBookStatus.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouBookStatus.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.integration.shangyou; +package com.bsd.pjcloud.supermarket.integration.shangyou; import cn.hutool.core.util.StrUtil; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java similarity index 92% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java index 2e3e04c39..3c00367b5 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackQueueWorker.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java @@ -1,7 +1,7 @@ -package com.bsd.pjcloud.canteen.integration.shangyou; +package com.bsd.pjcloud.supermarket.integration.shangyou; -import com.bsd.pjcloud.canteen.config.ShangyouHotelProperties; -import com.bsd.pjcloud.canteen.service.CanteenHotelCallbackTaskService; +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; @@ -26,7 +26,7 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner { private final StringRedisTemplate redisTemplate; - private final CanteenHotelCallbackTaskService callbackTaskService; + private final SmHotelCallbackTaskService callbackTaskService; private final AtomicBoolean running = new AtomicBoolean(true); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackSignHelper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackSignHelper.java similarity index 89% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackSignHelper.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackSignHelper.java index ad6023407..86e2c9120 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouCallbackSignHelper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackSignHelper.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.integration.shangyou; +package com.bsd.pjcloud.supermarket.integration.shangyou; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelApi.java similarity index 52% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelApi.java index 677847e01..bd0d021df 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelApi.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelApi.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.integration.shangyou; +package com.bsd.pjcloud.supermarket.integration.shangyou; /** * 玉编电云 CRS 上游路径(相对 biz-base-url / auth-base-url)。 @@ -24,40 +24,40 @@ public final class ShangyouHotelApi { public static final String ORDER_QUERY_BY_REQUEST_NO = "/order/queryByRequestNo"; - public static final String REDIS_TOKEN_KEY = "canteen:shangyou:hotel:token"; + public static final String REDIS_TOKEN_KEY = "supermarket:shangyou:hotel:token"; - public static final String REDIS_TOKEN_LOCK = "canteen:shangyou:hotel:token:lock"; + public static final String REDIS_TOKEN_LOCK = "supermarket:shangyou:hotel:token:lock"; /** 授权酒店 HASH:field=hotelId value=hotelName(适配十几万量级) */ - public static final String REDIS_HOTEL_HASH_KEY = "canteen:shangyou:hotel:hash:v4"; + 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 = "canteen:shangyou:hotel:zset:v4"; + public static final String REDIS_HOTEL_ZSET_KEY = "supermarket:shangyou:hotel:zset:v4"; /** 酒店列表同步锁 */ - public static final String REDIS_HOTEL_LIST_LOCK = "canteen:shangyou:hotel:list:lock"; + public static final String REDIS_HOTEL_LIST_LOCK = "supermarket:shangyou:hotel:list:lock"; /** 房价/房量回调异步队列(LIST,元素=taskId) */ - public static final String REDIS_CALLBACK_QUEUE = "canteen:shangyou:hotel:callback:queue"; + public static final String REDIS_CALLBACK_QUEUE = "supermarket:shangyou:hotel:callback:queue"; /** 上游授权 hotelId 集合(与 HASH/ZSET 同步重建,供 SDIFF) */ - public static final String REDIS_UPSTREAM_ID_SET = "canteen:shangyou:hotel:idset:v4"; + public static final String REDIS_UPSTREAM_ID_SET = "supermarket:shangyou:hotel:idset:v4"; /** 本地 hotel_code 集合(分批从 DB 重建) */ - public static final String REDIS_LOCAL_CODE_SET = "canteen:shangyou:hotel:local:codes:v1"; + public static final String REDIS_LOCAL_CODE_SET = "supermarket:shangyou:hotel:local:codes:v1"; /** 差集:上游有本地无(待导入) */ - public static final String REDIS_DIFF_NEW_SET = "canteen:shangyou:hotel:diff:new:v1"; + public static final String REDIS_DIFF_NEW_SET = "supermarket:shangyou:hotel:diff:new:v1"; /** 差集:本地有上游无(疑似下线) */ - public static final String REDIS_DIFF_OFFLINE_SET = "canteen:shangyou:hotel:diff:offline:v1"; + public static final String REDIS_DIFF_OFFLINE_SET = "supermarket:shangyou:hotel:diff:offline:v1"; /** 今日全量房价同步次数 */ - public static final String REDIS_FULL_PRICE_SYNC_COUNT = "canteen:shangyou:hotel:fullprice:count"; + public static final String REDIS_FULL_PRICE_SYNC_COUNT = "supermarket:shangyou:hotel:fullprice:count"; /** 上次全量房价同步时间 epochMs */ - public static final String REDIS_FULL_PRICE_SYNC_LAST = "canteen:shangyou:hotel:fullprice:last"; + 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 = "canteen:shangyou:hotel:price:qps"; + public static final String REDIS_PRICE_QPS_PREFIX = "supermarket:shangyou:hotel:price:qps"; } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelClient.java similarity index 99% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelClient.java index 3e4c1beec..258e92fa7 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouHotelClient.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelClient.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.integration.shangyou; +package com.bsd.pjcloud.supermarket.integration.shangyou; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpRequest; @@ -6,7 +6,7 @@ import cn.hutool.http.HttpResponse; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; -import com.bsd.pjcloud.canteen.config.ShangyouHotelProperties; +import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties; import com.bsd.pjcloud.common.core.exception.CheckedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -261,8 +261,8 @@ public class ShangyouHotelClient { redisTemplate.delete(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY); redisTemplate.delete(ShangyouHotelApi.REDIS_UPSTREAM_ID_SET); // 兼容清理旧版 LIST/压缩缓存 key - redisTemplate.delete("canteen:shangyou:hotel:list:v3"); - redisTemplate.delete("canteen:shangyou:hotel:list:v2"); + redisTemplate.delete("supermarket:shangyou:hotel:list:v3"); + redisTemplate.delete("supermarket:shangyou:hotel:list:v2"); } private List loadBriefsByIds(Set ids) { diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCallResult.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouOrderCallResult.java similarity index 97% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCallResult.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouOrderCallResult.java index 0c952eb10..615f0ccb9 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCallResult.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouOrderCallResult.java @@ -1,4 +1,4 @@ -package com.bsd.pjcloud.canteen.integration.shangyou; +package com.bsd.pjcloud.supermarket.integration.shangyou; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSONObject; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCompensateWorker.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouOrderCompensateWorker.java similarity index 89% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCompensateWorker.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouOrderCompensateWorker.java index 14356bc26..1c149ef2f 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/integration/shangyou/ShangyouOrderCompensateWorker.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouOrderCompensateWorker.java @@ -1,7 +1,7 @@ -package com.bsd.pjcloud.canteen.integration.shangyou; +package com.bsd.pjcloud.supermarket.integration.shangyou; -import com.bsd.pjcloud.canteen.config.ShangyouHotelProperties; -import com.bsd.pjcloud.canteen.service.CanteenHotelOrderService; +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; @@ -22,7 +22,7 @@ public class ShangyouOrderCompensateWorker implements ApplicationRunner { private final ShangyouHotelProperties properties; - private final CanteenHotelOrderService orderService; + private final SmHotelOrderService orderService; private final AtomicBoolean running = new AtomicBoolean(true); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSpringSchedule.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSpringSchedule.java similarity index 84% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSpringSchedule.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSpringSchedule.java index 4207a08c5..7f2ef02b4 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSpringSchedule.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSpringSchedule.java @@ -1,7 +1,7 @@ -package com.bsd.pjcloud.canteen.job; +package com.bsd.pjcloud.supermarket.job; -import com.bsd.pjcloud.canteen.config.ShangyouHotelProperties; -import com.bsd.pjcloud.canteen.service.CanteenHotelSyncService; +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; @@ -16,11 +16,11 @@ import org.springframework.stereotype.Component; @Slf4j @Component @RequiredArgsConstructor -public class CanteenHotelShangyouSpringSchedule { +public class SmHotelShangyouSpringSchedule { private final ShangyouHotelProperties properties; - private final CanteenHotelSyncService syncService; + private final SmHotelSyncService syncService; @Scheduled(cron = "${shangyou-hotel.hotel-list-daily-cron:0 0 2 * * ?}") public void scheduledListSync() { diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSyncJob.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSyncJob.java similarity index 66% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSyncJob.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSyncJob.java index ad6b82638..a90e0bf47 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/job/CanteenHotelShangyouSyncJob.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSyncJob.java @@ -1,6 +1,6 @@ -package com.bsd.pjcloud.canteen.job; +package com.bsd.pjcloud.supermarket.job; -import com.bsd.pjcloud.canteen.service.CanteenHotelSyncService; +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; @@ -20,36 +20,36 @@ import java.util.Map; @Slf4j @Service @RequiredArgsConstructor -public class CanteenHotelShangyouSyncJob { +public class SmHotelShangyouSyncJob { - public static final String HANDLER_LIST_SYNC = "canteenHotelListSyncJobHandler"; + public static final String HANDLER_LIST_SYNC = "smHotelListSyncJobHandler"; - public static final String HANDLER_PRICE_ROLL = "canteenHotelPriceRollJobHandler"; + public static final String HANDLER_PRICE_ROLL = "smHotelPriceRollJobHandler"; - private final CanteenHotelSyncService syncService; + private final SmHotelSyncService syncService; @XxlJob(HANDLER_LIST_SYNC) - public void canteenHotelListSyncJobHandler() { + public void smHotelListSyncJobHandler() { try { Map r = syncService.jobSyncHotelList(); XxlJobHelper.log("hotel list sync: " + r); XxlJobHelper.handleSuccess(String.valueOf(r)); } catch (Exception e) { - log.error("canteenHotelListSyncJobHandler 异常", e); + log.error("smHotelListSyncJobHandler 异常", e); XxlJobHelper.handleFail(e.getMessage()); } } @XxlJob(HANDLER_PRICE_ROLL) - public void canteenHotelPriceRollJobHandler() { + public void smHotelPriceRollJobHandler() { try { Map r = syncService.jobRollDailyPrice(); XxlJobHelper.log("hotel price roll: " + r); XxlJobHelper.handleSuccess(String.valueOf(r)); } catch (Exception e) { - log.error("canteenHotelPriceRollJobHandler 异常", e); + log.error("smHotelPriceRollJobHandler 异常", e); XxlJobHelper.handleFail(e.getMessage()); } } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelCallbackTaskMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelCallbackTaskMapper.java new file mode 100644 index 000000000..3f8221e83 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelCallbackTaskMapper.java @@ -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 { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java similarity index 79% rename from pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java rename to pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java index 46c4cd93d..6e40552f5 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenHotelDailyMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java @@ -1,6 +1,6 @@ -package com.bsd.pjcloud.canteen.mapper; +package com.bsd.pjcloud.supermarket.mapper; -import com.bsd.pjcloud.canteen.entity.CanteenHotelDailyEntity; +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; @@ -13,7 +13,7 @@ import java.util.List; import java.util.Map; @Mapper -public interface CanteenHotelDailyMapper extends PjcloudBaseMapper { +public interface SmHotelDailyMapper extends PjcloudBaseMapper { /** * 物理删除未来日历。唯一键 uk_product_date 不含 del_flag,软删后再插入会冲突。 @@ -21,7 +21,7 @@ public interface CanteenHotelDailyMapper extends PjcloudBaseMapper= #{fromDate}") int physicalDeleteFromDate(@Param("hotelId") Long hotelId, @Param("fromDate") LocalDate fromDate); - int insertBatch(@Param("list") List list); + int insertBatch(@Param("list") List list); @Select("""