diff --git a/pjcloud-app-server/pjcloud-app-server-api/src/main/java/com/bsd/pjcloud/app/api/feign/RemoteAppMenuService.java b/pjcloud-app-server/pjcloud-app-server-api/src/main/java/com/bsd/pjcloud/app/api/feign/RemoteAppMenuService.java new file mode 100644 index 000000000..7b4b00d1b --- /dev/null +++ b/pjcloud-app-server/pjcloud-app-server-api/src/main/java/com/bsd/pjcloud/app/api/feign/RemoteAppMenuService.java @@ -0,0 +1,45 @@ +/* + * + * 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.app.api.feign; + +import com.bsd.pjcloud.app.api.constant.menu.AppMenuPlatformEnum; +import com.bsd.pjcloud.app.api.constant.menu.AppMenuTypeEnum; +import com.bsd.pjcloud.app.api.entity.AppMenuEntity; +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 org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@FeignClient(contextId = "remoteAppMenuService", value = ServiceNameConstants.APP_SERVER) +public interface RemoteAppMenuService { + + /** + * 与 AppMenuController GET /app-menu/app/list 一致,内部调用 + */ + @NoToken + @GetMapping("/app-menu/inner/app/list") + R> innerAppList( + @RequestParam(value = "typeEnum", required = false) AppMenuTypeEnum typeEnum, + @RequestParam(value = "platformEnum", required = false) AppMenuPlatformEnum platformEnum); +} diff --git a/pjcloud-app-server/pjcloud-app-server-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports b/pjcloud-app-server/pjcloud-app-server-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports index 2c1b7d427..dd3586702 100644 --- a/pjcloud-app-server/pjcloud-app-server-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports +++ b/pjcloud-app-server/pjcloud-app-server-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports @@ -1 +1,2 @@ com.bsd.pjcloud.app.api.feign.RemoteAppUserService +com.bsd.pjcloud.app.api.feign.RemoteAppMenuService diff --git a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppMenuController.java b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppMenuController.java index cc276df23..a66f201d8 100755 --- a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppMenuController.java +++ b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppMenuController.java @@ -13,6 +13,7 @@ import com.bsd.pjcloud.app.api.entity.AppMenuEntity; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.app.service.AppMenuService; import org.springframework.security.access.prepost.PreAuthorize; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; @@ -25,7 +26,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.Objects; /** * 菜单管理 @@ -47,6 +47,20 @@ public class AppMenuController { @Operation(summary = "分页查询", description = "分页查询") @GetMapping("/app/list") public R> appList(@ParameterObject AppMenuDto dto) { + return listVisibleAppMenus(dto); + } + + /** + * 服务间调用:与 {@link #appList} 数据一致,需 Feign 携带内部调用头(见 {@code @NoToken} 与 {@link Inner})。 + */ + @Inner(value = true) + @Operation(summary = "内部-App菜单列表", description = "供 integrated 等经 Feign 拉取,与 /app-menu/app/list 一致") + @GetMapping("/inner/app/list") + public R> innerAppList(@ParameterObject AppMenuDto dto) { + return listVisibleAppMenus(dto); + } + + private R> listVisibleAppMenus(AppMenuDto dto) { dto.setVisible(YesNoEnum.YES.getCode()); if (ObjectUtil.isNotEmpty(dto.getPlatformEnum())) { dto.setPlatform(dto.getPlatformEnum().getType()); 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 589d0e7db..a3c22af2c 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 @@ -38,10 +38,9 @@ import com.bsd.pjcloud.common.log.annotation.SysLog; 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.UrlTokenDTO; 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; @@ -90,111 +89,18 @@ public class AppUserAutoLoginController { private final RemoteOnlineCanteenSpuService remoteOnlineCanteenSpuService; private final RemoteDailyMenuService remoteDailyMenuService; - /* - * - * @param token 浙政钉前端获取的token - * @return + /** + * 路径参数历史命名为 token,实为浙政钉 H5 {@code authCode},转发 thirdParty 统一单点(含短信核验等逻辑)。 */ @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); + UrlTokenDTO body = new UrlTokenDTO(); + body.setAuthCode(token); + return remoterThirdPartyAuthService.zzdsso(body); } diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/util/R.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/util/R.java index e728b7029..b743ed9da 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/util/R.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/util/R.java @@ -81,6 +81,13 @@ public class R implements Serializable { return restResult(null, CommonConstants.FAIL, msg); } + /** + * 失败并指定业务码(非 0),便于前端区分提示(如浙政钉绑定后无系统用户)。 + */ + public static R failed(int code, String msg) { + return restResult(null, code, msg); + } + public static R failed(T data) { return restResult(data, CommonConstants.FAIL, null); } diff --git a/pjcloud-integrated/pjcloud-integrated-biz/pom.xml b/pjcloud-integrated/pjcloud-integrated-biz/pom.xml index c64626fdd..35a41449b 100644 --- a/pjcloud-integrated/pjcloud-integrated-biz/pom.xml +++ b/pjcloud-integrated/pjcloud-integrated-biz/pom.xml @@ -91,6 +91,11 @@ pjcloud-thirdparty-api 5.7.0 + + com.bsd + pjcloud-app-server-api + 5.7.0 + com.bsd diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/AppMenuOpenController.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/AppMenuOpenController.java new file mode 100644 index 000000000..68bcd0586 --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/AppMenuOpenController.java @@ -0,0 +1,74 @@ +package com.bsd.pjcloud.integrated.controller; + +import cn.hutool.core.util.StrUtil; +import com.bsd.pjcloud.app.api.constant.menu.AppMenuPlatformEnum; +import com.bsd.pjcloud.app.api.constant.menu.AppMenuTypeEnum; +import com.bsd.pjcloud.app.api.entity.AppMenuEntity; +import com.bsd.pjcloud.app.api.feign.RemoteAppMenuService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.integrated.config.ExternalAppConfig; +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * App 端菜单:开放路径凭 X-App-Id/Secret;已登录路径与 {@code /app-menu/app/list} 数据一致,经 Feign 调 app-server(租户头随 Feign 传递)。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/appMenu") +@Tag(description = "appMenuOpen", name = "开放-App菜单") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class AppMenuOpenController { + + private final ExternalAppConfig externalAppConfig; + + private final RemoteAppMenuService remoteAppMenuService; + + /** + * 已登录(Bearer):与 app-server GET /app-menu/app/list 一致,走 integrated 统一前缀。 + */ + @Operation(summary = "App菜单列表", description = "需登录,转发 app-server 内部菜单接口") + @GetMapping("/app/list") + public R> appList( + @RequestParam(value = "typeEnum", required = false) AppMenuTypeEnum typeEnum, + @RequestParam(value = "platformEnum", required = false) AppMenuPlatformEnum platformEnum) { + return remoteAppMenuService.innerAppList(typeEnum, platformEnum); + } + + @Operation(summary = "开放-App菜单列表", description = "凭应用凭证访问,与 app-server /app-menu/app/list 数据一致") + @GetMapping("/open/app/list") + @Inner(value = false) + public R> openAppList( + @RequestHeader(value = "X-App-Id", required = false) String appId, + @RequestHeader(value = "X-App-Secret", required = false) String appSecret, + @RequestParam(value = "typeEnum", required = false) AppMenuTypeEnum typeEnum, + @RequestParam(value = "platformEnum", required = false) AppMenuPlatformEnum platformEnum) { + if (!isValidAppCredentials(appId, appSecret)) { + return R.failed("无效的应用ID或密钥"); + } + return remoteAppMenuService.innerAppList(typeEnum, platformEnum); + } + + private boolean isValidAppCredentials(String appId, String appSecret) { + if (StrUtil.isBlank(appId) || StrUtil.isBlank(appSecret)) { + return false; + } + if (externalAppConfig.getApps() != null) { + return externalAppConfig.getApps().stream() + .filter(ExternalAppConfig.AppCredential::getEnabled) + .anyMatch(app -> appId.equals(app.getAppId()) && appSecret.equals(app.getAppSecret())); + } + return false; + } +} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/NoticeController.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/NoticeController.java index 880e68e20..3d6811c82 100644 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/NoticeController.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/NoticeController.java @@ -171,6 +171,46 @@ public class NoticeController { return R.ok(notice); } + /** + * IRS / 纯静态托管 H5:与 {@link #getAppNoticePage} 数据一致,凭 X-App-Id、X-App-Secret({@code pjcloud.external-app})访问,不依赖用户 OAuth2 Token。 + */ + @Operation(summary = "开放-公告分页", description = "凭应用凭证访问,供无法携带登录 Token 的 H5") + @GetMapping("/open/page") + @Inner(value = false) + public R getOpenNoticePage( + @RequestHeader(value = "X-App-Id", required = false) String appId, + @RequestHeader(value = "X-App-Secret", required = false) String appSecret, + @ParameterObject Page page, + @RequestParam(required = false) String title) { + if (!isValidAppCredentials(appId, appSecret)) { + return R.failed("无效的应用ID或密钥"); + } + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.eq(NoticeEntity::getStatus, "0"); + wrapper.like(StrUtil.isNotBlank(title), NoticeEntity::getTitle, title); + wrapper.and(w -> w.isNull(NoticeEntity::getExpireTime) + .or() + .gt(NoticeEntity::getExpireTime, LocalDateTime.now())); + wrapper.orderByDesc(NoticeEntity::getIsTop, NoticeEntity::getCreateTime); + return R.ok(noticeService.page(page, wrapper)); + } + + /** + * IRS / 纯静态托管 H5:与 {@link #getAppNoticeDetail} 一致,凭应用凭证访问。 + */ + @Operation(summary = "开放-公告详情", description = "凭应用凭证访问") + @GetMapping("/open/{id}") + @Inner(value = false) + public R getOpenNoticeDetail( + @RequestHeader(value = "X-App-Id", required = false) String appId, + @RequestHeader(value = "X-App-Secret", required = false) String appSecret, + @PathVariable("id") Long id) { + if (!isValidAppCredentials(appId, appSecret)) { + return R.failed("无效的应用ID或密钥"); + } + return getAppNoticeDetail(id); + } + /** * App公告浏览量+1 * @param id 公告ID diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/SysBannerController.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/SysBannerController.java index 5f084ce2f..79762b736 100644 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/SysBannerController.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/SysBannerController.java @@ -136,15 +136,43 @@ public class SysBannerController { return R.failed("无效的应用ID或密钥"); } + return R.ok(buildAppBannerVoList()); + } + + /** + * H5 / 浙政钉工作台(zzdhome)轮播:与 {@link #appList} 数据一致,无需 X-App-Id/Secret,便于前端直接拉取。 + * 若需对外收口鉴权,可在网关按路径单独配置。 + */ + @Operation(summary = "H5/App轮播图列表", description = "上架且在有效期内,字段同外部 app 接口;图片 URL 规则与 /app/list 一致") + @GetMapping("/app/slides") + @Inner(value = false) + public R> appSlides() { + return R.ok(buildAppBannerVoList()); + } + + /** + * IRS / 无法携带用户 Token 的 H5:与 {@link #appList} 相同鉴权与数据,路径便于与公告 {@code /notice/open/*} 一并加入网关放行。 + */ + @Operation(summary = "开放-轮播图列表", description = "凭 X-App-Id、X-App-Secret 访问,同 /app/list") + @GetMapping("/open/slides") + @Inner(value = false) + public R> openSlides( + @RequestHeader(value = "X-App-Id", required = false) String appId, + @RequestHeader(value = "X-App-Secret", required = false) String appSecret) { + if (!isValidAppCredentials(appId, appSecret)) { + return R.failed("无效的应用ID或密钥"); + } + return R.ok(buildAppBannerVoList()); + } + + private List buildAppBannerVoList() { String imageBaseUrl = bannerAppConfig.getImageBaseUrl(); boolean needPrefix = StrUtil.isNotBlank(imageBaseUrl); String baseUrl = needPrefix ? imageBaseUrl.trim() : ""; - List list = sysBannerService.listAppBanners(); - List voList = list.stream() + return list.stream() .map(e -> { SysBannerAppVo vo = BeanUtils.toBean(e, SysBannerAppVo.class); - // imageUrl 前拼接统一基础路径 if (needPrefix && StrUtil.isNotBlank(e.getImageUrl())) { String path = e.getImageUrl().trim(); if (!path.startsWith("http://") && !path.startsWith("https://")) { @@ -159,7 +187,6 @@ public class SysBannerController { return vo; }) .collect(Collectors.toList()); - return R.ok(voList); } /** 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..61187b414 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 @@ -21,6 +21,7 @@ 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.dto.UrlTokenDTO; import com.bsd.pjcloud.thirdparty.entity.ThirdPartyUserEntity; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.*; @@ -35,13 +36,11 @@ import java.util.List; public interface RemoterThirdPartyAuthService { /** - * zzd单点 - * @param token - * @return + * 浙政钉单点(与 thirdPartyOAth2/zzdsso 一致,body.authCode 为 H5 免登码) */ @NoToken - @GetMapping("/thirdPartyOAth2/zzd/sso") - R zzdsso(@RequestParam("token") String token); + @PostMapping("/thirdPartyOAth2/zzdsso") + R zzdsso(@RequestBody UrlTokenDTO body); @NoToken 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 index 7e2c48744..88a81ef4a 100644 --- 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 @@ -5,5 +5,12 @@ import lombok.Data; @Data public class UrlTokenDTO { + /** + * 已废弃:浙政钉单点仅使用 authCode,不再传网关 uabaseToken + */ private String token; + /** + * 浙政钉 H5 官方 dd.getAuthCode 返回的临时码 + */ + private String authCode; } 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 index 3d849dc2c..489b08250 100644 --- 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 @@ -2,6 +2,11 @@ package com.bsd.pjcloud.thirdparty.dto; import lombok.Data; +/** + * 第三方 SSO 在本服务内的统一用户视图(非开放平台原始报文体)。 + * 浙政钉:由 {@code dingtalk_app_user} 等适配层填入;诗画:由网关用户信息接口填入。 + * 后续业务(发 token、短信绑定快照等)只依赖本结构,与走网关还是走开放平台无关。 + */ @Data public class ZZDUserDTO { private String username; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZzdDingtalkAppUserRaw.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZzdDingtalkAppUserRaw.java new file mode 100644 index 000000000..d9faf6137 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZzdDingtalkAppUserRaw.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.thirdparty.dto; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 浙政钉开放平台 {@code /rpc/oauth2/dingtalk_app_user.json} 返回的 data 段(文档字段名与线上实际 key 可能为驼峰或下划线)。 + *

+ * 由业务层从开放平台 JSON 解析填入,再统一映射为 {@link ZZDUserDTO}。 + */ +@Data +public class ZzdDingtalkAppUserRaw implements Serializable { + + private static final long serialVersionUID = 1L; + + private String lastName; + + private String nickNameCn; + + private String mobile; + + private Integer organizationId; + + private Long accountId; + + private String account; + + private String employeeCode; + + private String openid; + + private String realmId; + + private String organizationCode; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZzdPhoneConfirmDTO.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZzdPhoneConfirmDTO.java new file mode 100644 index 000000000..210db4b20 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZzdPhoneConfirmDTO.java @@ -0,0 +1,16 @@ +package com.bsd.pjcloud.thirdparty.dto; + +import lombok.Data; + +@Data +public class ZzdPhoneConfirmDTO { + + /** + * /zzdsso 返回的 bindSessionId + */ + private String bindSessionId; + + private String mobilePhone; + + private String smsCode; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZzdUserSnapshot.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZzdUserSnapshot.java new file mode 100644 index 000000000..42d3df9a9 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/ZzdUserSnapshot.java @@ -0,0 +1,47 @@ +package com.bsd.pjcloud.thirdparty.dto; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 浙政钉会话快照(可 JSON 序列化入 Redis),避免 ZZDUserDTO 内部类反序列化问题。 + */ +@Data +public class ZzdUserSnapshot implements Serializable { + + private static final long serialVersionUID = 1L; + + private String username; + + private String nickName; + + /** 浙政钉接口若返回的顶层手机号 */ + private String zzdMobile; + + private String employeeCode; + + private String openid; + + private String organizationCode; + + private String realmId; + + public static ZzdUserSnapshot from(ZZDUserDTO dto) { + ZzdUserSnapshot s = new ZzdUserSnapshot(); + if (dto == null) { + return s; + } + s.setUsername(dto.getUsername()); + s.setNickName(dto.getNickName()); + s.setZzdMobile(dto.getMobile()); + ZZDUserDTO.Ext ext = dto.getExt(); + if (ext != null) { + s.setEmployeeCode(ext.getEmployeeCode()); + s.setOpenid(ext.getOpenid()); + s.setOrganizationCode(ext.getOrganizationCode()); + s.setRealmId(ext.getRealmId()); + } + return s; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/pom.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/pom.xml index cb431bbcf..81f09a7fa 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/pom.xml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/pom.xml @@ -155,6 +155,19 @@ 5.7.0 compile + + + com.alibaba.xxpt + zwdd-sdk-java + 1.2.0 + system + ${project.basedir}/src/lib/zwdd-sdk-java-1.2.0.jar + + + joda-time + joda-time + 2.10 + @@ -190,6 +203,7 @@ CLASSIC + true diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/lib/zwdd-sdk-java-1.2.0.jar b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/lib/zwdd-sdk-java-1.2.0.jar new file mode 100644 index 000000000..497940666 Binary files /dev/null and b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/lib/zwdd-sdk-java-1.2.0.jar differ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/ThirdPartyApp.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/ThirdPartyApp.java index 4c92e0396..88f0a2699 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/ThirdPartyApp.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/ThirdPartyApp.java @@ -3,8 +3,10 @@ package com.bsd.pjcloud.thirdparty; import com.bsd.pjcloud.common.feign.annotation.EnablePjcloudFeignClients; import com.bsd.pjcloud.common.security.annotation.EnablePjcloudResourceServer; import com.bsd.pjcloud.common.swagger.annotation.EnableOpenApi; +import com.bsd.pjcloud.thirdparty.config.ThirdPartyZzdSmsBindProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; /** @@ -16,6 +18,7 @@ import org.springframework.cloud.client.discovery.EnableDiscoveryClient; @EnablePjcloudFeignClients @EnableDiscoveryClient @EnablePjcloudResourceServer +@EnableConfigurationProperties(ThirdPartyZzdSmsBindProperties.class) @SpringBootApplication public class ThirdPartyApp { public static void main(String[] args) { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/OAuth2ClientConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/OAuth2ClientConfig.java index d2e381b8e..c45e61a9a 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,18 +23,13 @@ public class OAuth2ClientConfig { private String userDetailUrl; - /** - * 浙政钉获取token的接口 - */ - private String zzdTokenUrl; - /** * 诗画江南的token接口 */ private String shjnTokenUrl; /** - * 浙政钉跟诗画的获取用户信息的url + * 诗画江南 uabaseToken 换用户信息(网关 GET ?token=) */ private String zzdShUserInfoUrl; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/ThirdPartyZzdSmsBindProperties.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/ThirdPartyZzdSmsBindProperties.java new file mode 100644 index 000000000..bae3018f2 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/ThirdPartyZzdSmsBindProperties.java @@ -0,0 +1,22 @@ +package com.bsd.pjcloud.thirdparty.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 浙政钉无手机号时:首次短信核验 + 与本系统用户手机号比对后再发 token。 + */ +@Data +@ConfigurationProperties(prefix = "pjcloud.thirdparty.zzd-sms-bind") +public class ThirdPartyZzdSmsBindProperties { + + /** + * 是否开启(关闭时 /zzdsso 在拿到浙政钉用户后直接按手机号+员工编码查用户并发 token;无手机号的新用户应开启) + */ + private boolean enabled = true; + + /** + * 绑定会话在 Redis 中的有效时间(分钟) + */ + private int bindSessionTtlMinutes = 15; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/ZzdOpenPlatformExecutableClientHolder.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/ZzdOpenPlatformExecutableClientHolder.java new file mode 100644 index 000000000..dbe3a8726 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/ZzdOpenPlatformExecutableClientHolder.java @@ -0,0 +1,41 @@ +package com.bsd.pjcloud.thirdparty.config; + +import com.alibaba.xxpt.gateway.shared.client.http.ExecutableClient; +import com.bsd.pjcloud.thirdparty.exception.ThirdPartyException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 政务钉钉开放平台 {@link ExecutableClient} 单例初始化(与官方 SDK 文档一致,全局只 init 一次)。 + */ +@Component +@RequiredArgsConstructor +public class ZzdOpenPlatformExecutableClientHolder { + + private final ZzdOpenPlatformProperties zzdOpenPlatform; + + private final Object initLock = new Object(); + + private volatile boolean initialized; + + public ExecutableClient client() { + if (!zzdOpenPlatform.isEnabled() || !zzdOpenPlatform.isAppCredentialConfigured()) { + throw new ThirdPartyException("浙政钉开放平台未启用或未配置 appKey/appSecret", "ZZD_OPEN_SDK_CFG"); + } + if (!initialized) { + synchronized (initLock) { + if (!initialized) { + ExecutableClient c = ExecutableClient.getInstance(); + c.setDomainName(zzdOpenPlatform.getDomain()); + c.setProtocal(zzdOpenPlatform.getProtocol()); + c.setAccessKey(zzdOpenPlatform.getAppKey()); + c.setSecretKey(zzdOpenPlatform.getAppSecret()); + c.setValidateAfterInactivity(60_000); + c.init(); + initialized = true; + } + } + } + return ExecutableClient.getInstance(); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/ZzdOpenPlatformProperties.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/ZzdOpenPlatformProperties.java new file mode 100644 index 000000000..cab441374 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/ZzdOpenPlatformProperties.java @@ -0,0 +1,43 @@ +package com.bsd.pjcloud.thirdparty.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 浙政钉开放平台(正式:openplatform-pro.ding.zj.gov.cn)企业内应用免登。 + * app-key / app-secret 请放在 Nacos 或环境变量,勿提交仓库。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "zzd.open") +public class ZzdOpenPlatformProperties { + + /** + * 为 false 时禁用开放平台 SDK;浙政钉 authCode 单点不可用(需为 true 且配置 appKey/appSecret) + */ + private boolean enabled = true; + + private String protocol = "https"; + + private String domain = "openplatform-pro.ding.zj.gov.cn"; + + /** + * 租户 ID(与管理工作台源码中 realmId、前端 corpId 一致,用于核对日志) + */ + private String tenantId = "196729"; + + private String appKey = ""; + + private String appSecret = ""; + + /** + * H5 微应用 JSAPI 鉴权用 AgentId(管理后台应用详情,非 appKey) + */ + private String jsapiAgentId = ""; + + public boolean isAppCredentialConfigured() { + return StringUtils.hasText(appKey) && StringUtils.hasText(appSecret); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdPartyZzdBizCodes.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdPartyZzdBizCodes.java new file mode 100644 index 000000000..fdee32a99 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdPartyZzdBizCodes.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.thirdparty.constant; + +/** + * 浙政钉单点 / 短信绑定相关前端可识别业务码(与 {@link com.bsd.pjcloud.common.core.util.R#code} 一致)。 + */ +public final class ThirdPartyZzdBizCodes { + + private ThirdPartyZzdBizCodes() { + } + + /** 短信核验通过,但 sys_user 中不存在该手机号用户 */ + public static final int SYS_USER_NOT_FOUND_FOR_ZZD_BIND = 46001; +} 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 bb29441a8..6dec3e9fb 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 @@ -17,15 +17,23 @@ import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.thirdparty.dto.*; import com.bsd.pjcloud.thirdparty.service.SmsService; +import com.bsd.pjcloud.thirdparty.exception.ThirdPartyException; +import com.bsd.pjcloud.thirdparty.vo.ZzdJsapiConfigVO; +import com.bsd.pjcloud.thirdparty.constant.ThirdPartyZzdBizCodes; +import com.bsd.pjcloud.thirdparty.entity.ThirdPartyZzdPhoneBindEntity; import com.bsd.pjcloud.thirdparty.service.ThirdPartyOAuth2Service; import com.bsd.pjcloud.thirdparty.service.ThirdPartyUserService; +import com.bsd.pjcloud.thirdparty.service.ThirdPartyZzdSmsBindService; +import com.fasterxml.jackson.core.JsonProcessingException; import com.bsd.pjcloud.thirdparty.utils.SmsCodeUtil; +import com.alibaba.fastjson.JSON; 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 lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.seata.spring.annotation.GlobalTransactional; import org.springframework.beans.factory.annotation.Autowired; @@ -41,6 +49,7 @@ import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.TimeUnit; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/thirdPartyOAth2") @@ -68,12 +77,7 @@ public class ThirdPartyOAth2Cotroller { private final RemoteUserTrustedDeviceService remoteUserTrustedDeviceService; -// @Operation(summary = "浙政钉token" ) -// @GetMapping("/zzd/sso" ) -// public R getThirdPartyzzdsso(@RequestParam("token")String token) { -// return R.ok(thirdPartyOAuth2Service.getZZDUserInfo(token)); -// -// } + private final ThirdPartyZzdSmsBindService thirdPartyZzdSmsBindService; @Operation(summary = "诗画token") @GetMapping("/sh/sso") @@ -82,11 +86,17 @@ public class ThirdPartyOAth2Cotroller { } - /* - * - * @param token 浙政钉前端获取的token - * @return - */ + @GetMapping("/zzdJsapiConfig") + @Operation(summary = "政务钉钉H5 JSAPI鉴权(dd.authConfig 所需 ticket)") + @Inner(value = false) + public R zzdJsapiConfig() { + try { + return R.ok(thirdPartyOAuth2Service.buildZzdJsapiConfigForH5()); + } catch (ThirdPartyException e) { + return R.failed(e.getMessage()); + } + } + @PostMapping("/zzdsso") @Operation(summary = "浙政钉单点登录") @SysLog("浙政钉单点登录") @@ -94,108 +104,117 @@ public class ThirdPartyOAth2Cotroller { @GlobalTransactional @Transactional(rollbackFor = Exception.class) public R zzdsso(@RequestBody UrlTokenDTO urlTokenDTO) { - String token = urlTokenDTO.getToken(); - if (StringUtils.isBlank(token)) { - return R.failed("token不能为空"); + String authCode = urlTokenDTO != null ? urlTokenDTO.getAuthCode() : null; + if (StringUtils.isBlank(authCode)) { + return R.failed("请传入浙政钉 authCode(H5 免登临时码)"); } - //获取数字政务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 zzdUserDTO = thirdPartyOAuth2Service.zzdssoByAuthCode(authCode.trim()); + log.warn("[zzdsso][ZZDUserDTO_JSON] 浙政钉开放平台映射后的对象,可整段复制做本地写死调试(含姓名手机号等敏感信息,调试完建议关日志或删此行) body={}", JSON.toJSONString(zzdUserDTO)); ZZDUserDTO.Ext ext = zzdUserDTO.getExt(); - String employeeCode = ext.getEmployeeCode(); + String employeeCode = ext != null ? ext.getEmployeeCode() : null; + String zzdOpenId = ext != null ? ext.getOpenid() : null; 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("系统中没有当前用户信息,请联系管理员进行申请!"); + if (thirdPartyZzdSmsBindService.isEnabled()) { + var boundOpt = thirdPartyZzdSmsBindService.findBound(employeeCode, zzdOpenId); + if (boundOpt.isPresent()) { + ThirdPartyZzdPhoneBindEntity bound = boundOpt.get(); + return buildZzdSsoTokenResult(bound.getEmployeeCode(), bound.getVerifiedPhone()); + } + try { + ZzdUserSnapshot snap = ThirdPartyZzdSmsBindService.snapshotOf(zzdUserDTO); + if (StringUtils.isBlank(snap.getEmployeeCode()) && StringUtils.isBlank(snap.getOpenid())) { + return R.failed("浙政钉未返回员工编码或 openid,无法绑定登录"); + } + if (StringUtils.isBlank(snap.getEmployeeCode())) { + return R.failed("浙政钉未返回员工编码,无法完成首次手机号绑定,请在开放平台核对应用权限或联系管理员。"); + } + String bindSessionId = thirdPartyZzdSmsBindService.createBindSession(snap); + HashMap need = new HashMap<>(); + need.put("needSmsVerify", Boolean.TRUE); + need.put("bindSessionId", bindSessionId); + need.put("displayName", StringUtils.defaultIfBlank(zzdUserDTO.getNickName(), zzdUserDTO.getUsername())); + return R.ok(need); + } + catch (JsonProcessingException e) { + return R.failed("创建绑定会话失败"); + } } - //两个表都没查到人员不能够使用移动端的系统 + return buildZzdSsoTokenResult(employeeCode, mobile); + } -// log.info("用户不存在,尝试创建用户"); + /** + * 浙政钉短信核验:校验验证码、写入绑定表、与本系统用户手机号+员工编码比对后返回 token。 + */ + @PostMapping("/zzdsso/confirmPhone") + @Operation(summary = "浙政钉单点-短信核验手机号") + @SysLog("浙政钉单点短信核验") + @Inner(value = false) + @Transactional(rollbackFor = Exception.class) + public R confirmZzdPhoneBind(@RequestBody ZzdPhoneConfirmDTO dto) { + if (dto == null || StringUtils.isAnyBlank(dto.getBindSessionId(), dto.getMobilePhone(), dto.getSmsCode())) { + return R.failed("参数不完整"); + } + try { + ZzdUserSnapshot snap = thirdPartyZzdSmsBindService.getBindSession(dto.getBindSessionId()); + if (snap == null) { + return R.failed("绑定会话已过期,请重新登录"); + } + if (StringUtils.isBlank(snap.getEmployeeCode())) { + return R.failed("会话数据异常"); + } + String redisKey = CacheConstants.SMS_CODE_KEY + dto.getMobilePhone(); + String storedCode = (String) redisTemplate.opsForValue().get(redisKey); + if (!dto.getSmsCode().equals(storedCode)) { + return R.failed("验证码错误"); + } + redisTemplate.delete(redisKey); -// 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); + R> byPhone = remoteUserService.getUserByMobile(dto.getMobilePhone()); + List users = byPhone.getData(); + if (CollectionUtils.isEmpty(users)) { + return R.failed(ThirdPartyZzdBizCodes.SYS_USER_NOT_FOUND_FOR_ZZD_BIND, + "本系统暂无该手机号对应的用户,请联系管理员申请开通账号后再完成绑定。"); + } + SysUser sysUser = users.get(0); + if (StringUtils.isNotBlank(sysUser.getDingtalkEmployeeCode()) + && StringUtils.isNotBlank(snap.getEmployeeCode()) + && !sysUser.getDingtalkEmployeeCode().equals(snap.getEmployeeCode())) { + return R.failed("浙政钉账号与本系统用户不一致"); + } + + thirdPartyZzdSmsBindService.recordVerifiedBind(snap, dto.getMobilePhone()); + thirdPartyZzdSmsBindService.removeBindSession(dto.getBindSessionId()); + + UserTokenDTO userTokenDTO = new UserTokenDTO(); + userTokenDTO.setSysUser(sysUser); + R r = remoteTokenService.generateToken(userTokenDTO); + HashMap out = new HashMap<>(); + out.put("refreshToken", r.getData().get("refreshToken")); + out.put("accessToken", r.getData().get("accessToken")); + return R.ok(out); + } + catch (JsonProcessingException e) { + return R.failed("保存绑定信息失败"); + } + } + + private R buildZzdSsoTokenResult(String employeeCode, String mobileForLookup) { + R> userByMobileOrEmployeeCode = remoteUserService.getUserByMobileOrEmployeeCode( + StringUtils.defaultString(employeeCode), + StringUtils.defaultString(mobileForLookup)); + List sysUserList = userByMobileOrEmployeeCode.getData(); + if (CollectionUtils.isEmpty(sysUserList)) { + return R.failed("系统中没有当前用户信息,请联系管理员进行申请!"); + } + UserTokenDTO userTokenDTO = new UserTokenDTO(); + userTokenDTO.setSysUser(sysUserList.get(0)); + 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); + objectObjectHashMap.put("refreshToken", r.getData().get("refreshToken")); + objectObjectHashMap.put("accessToken", r.getData().get("accessToken")); return R.ok(objectObjectHashMap); } @@ -396,4 +415,7 @@ public class ThirdPartyOAth2Cotroller { } + + + } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/entity/ThirdPartyZzdPhoneBindEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/entity/ThirdPartyZzdPhoneBindEntity.java new file mode 100644 index 000000000..4bd75fe6f --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/entity/ThirdPartyZzdPhoneBindEntity.java @@ -0,0 +1,63 @@ +package com.bsd.pjcloud.thirdparty.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 浙政钉用户短信核验手机号绑定 + */ +@Data +@TenantTable +@TableName("third_party_zzd_phone_bind") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "浙政钉短信核验手机号绑定") +public class ThirdPartyZzdPhoneBindEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键") + private Long id; + + @Schema(description = "租户ID") + private Long tenantId; + + @Schema(description = "浙政钉员工编码") + private String employeeCode; + + @Schema(description = "openid") + private String openId; + + @Schema(description = "核验手机号") + private String verifiedPhone; + + @Schema(description = "浙政钉信息快照JSON") + private String zzdProfileJson; + + @Schema(description = "删除标记") + private String delFlag; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新人") + private String updateBy; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/ThirdPartyZzdPhoneBindMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/ThirdPartyZzdPhoneBindMapper.java new file mode 100644 index 000000000..8665ac532 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/ThirdPartyZzdPhoneBindMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.thirdparty.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.thirdparty.entity.ThirdPartyZzdPhoneBindEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ThirdPartyZzdPhoneBindMapper extends PjcloudBaseMapper { +} 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 2930afad8..9bbf043cb 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 @@ -3,13 +3,28 @@ 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.alibaba.fastjson.JSONObject; +import com.alibaba.xxpt.gateway.shared.api.request.OapiGetJsapiTokenJsonRequest; +import com.alibaba.xxpt.gateway.shared.api.request.OapiGettokenJsonRequest; +import com.alibaba.xxpt.gateway.shared.api.request.OapiRpcOauth2DingtalkAppUserJsonRequest; +import com.alibaba.xxpt.gateway.shared.api.response.OapiGetJsapiTokenJsonResponse; +import com.alibaba.xxpt.gateway.shared.api.response.OapiGettokenJsonResponse; +import com.alibaba.xxpt.gateway.shared.api.response.OapiRpcOauth2DingtalkAppUserJsonResponse; +import com.alibaba.xxpt.gateway.shared.client.http.ExecutableClient; +import com.alibaba.xxpt.gateway.shared.client.http.IntelligentGetClient; +import com.alibaba.xxpt.gateway.shared.client.http.IntelligentPostClient; +import com.alibaba.xxpt.gateway.shared.client.http.api.OapiResponse; +import com.alibaba.xxpt.gateway.shared.client.http.api.OapiSpResultContent; import com.bsd.pjcloud.thirdparty.config.OAuth2ClientConfig; +import com.bsd.pjcloud.thirdparty.config.ZzdOpenPlatformExecutableClientHolder; +import com.bsd.pjcloud.thirdparty.config.ZzdOpenPlatformProperties; 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.vo.ZzdJsapiConfigVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; @@ -40,6 +55,17 @@ public class ThirdPartyOAuth2Service { private static final RestTemplate restTemplate = new RestTemplate(); + private final Object zzdOpenTokenLock = new Object(); + + private volatile String zzdOpenCachedAppAccessToken; + private volatile long zzdOpenCachedAppAccessTokenExpireMs; + + private final Object zzdJsapiTicketLock = new Object(); + + private volatile String zzdOpenCachedJsapiTicket; + + private volatile long zzdOpenCachedJsapiTicketExpireMs; + @Autowired private OAuth2ClientConfig config; private final String baseUrl = "http://192.168.93.121:8880"; @@ -55,6 +81,10 @@ public class ThirdPartyOAuth2Service { private final ThirdPartyLogService thirdPartyLogService; + private final ZzdOpenPlatformProperties zzdOpenPlatform; + + private final ZzdOpenPlatformExecutableClientHolder zzdExecutableClientHolder; + public TokenDTO getAccessToken() { TokenDTO cachedToken = tokenCache.get("access_token"); @@ -357,45 +387,305 @@ public class ThirdPartyOAuth2Service { /** - * 获取浙政钉的token - * @param token - * @return + * 浙政钉 H5:仅通过开放平台 authCode + dingtalk_app_user 换用户信息(已移除旧网关 uabaseToken / 网关 authCode 接口)。 */ - 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; + public ZZDUserDTO zzdssoByAuthCode(String authCode) { + if (zzdOpenPlatform.isEnabled() && zzdOpenPlatform.isAppCredentialConfigured()) { + return getZzdUserByOpenPlatformAuthCode(authCode); } + throw new ThirdPartyException("浙政钉单点需配置 zzd.open.enabled 与 app-key/app-secret(开放平台企业内应用)", "ZZD_OPEN_CONFIG"); + } /** - * 浙证钉和诗画工用 - * @param zzdToken - * @return + * 开放平台:官方 SDK {@code IntelligentGetClient /gettoken.json},解析 content.data 中 accessToken */ - public ZZDUserDTO getZZDUserInfo(String zzdToken,String type) { + private String getZzdOpenAppAccessToken() { + long now = System.currentTimeMillis(); + if (zzdOpenCachedAppAccessToken != null && now < zzdOpenCachedAppAccessTokenExpireMs - 60_000L) { + return zzdOpenCachedAppAccessToken; + } + synchronized (zzdOpenTokenLock) { + now = System.currentTimeMillis(); + if (zzdOpenCachedAppAccessToken != null && now < zzdOpenCachedAppAccessTokenExpireMs - 60_000L) { + return zzdOpenCachedAppAccessToken; + } + try { + ExecutableClient ec = zzdExecutableClientHolder.client(); + IntelligentGetClient getClient = ec.newIntelligentGetClient("/gettoken.json"); + OapiGettokenJsonRequest req = new OapiGettokenJsonRequest(); + req.setAppkey(zzdOpenPlatform.getAppKey()); + req.setAppsecret(zzdOpenPlatform.getAppSecret()); + OapiGettokenJsonResponse resp = getClient.get(req); + zzdAssertSdkTopSuccess(resp, "gettoken"); + JSONObject data = zzdSdkSpResultData(resp.getContent(), "gettoken"); + String accessToken = data.getString("accessToken"); + if (!StringUtils.hasText(accessToken)) { + throw new ThirdPartyException("浙政钉 gettoken 未返回 accessToken", "ZZD_OPEN_TOKEN_BAD"); + } + int expiresIn = 7100; + try { + Object ex = data.get("expiresIn"); + if (ex != null) { + expiresIn = Integer.parseInt(ex.toString()); + } + } catch (NumberFormatException ignored) { + } + zzdOpenCachedAppAccessToken = accessToken; + zzdOpenCachedAppAccessTokenExpireMs = System.currentTimeMillis() + expiresIn * 1000L; + return accessToken; + } catch (ThirdPartyException e) { + throw e; + } catch (Exception e) { + log.error("浙政钉 gettoken SDK 调用异常", e); + throw new ThirdPartyException("浙政钉获取应用 access_token 失败", "ZZD_OPEN_TOKEN_SDK"); + } + } + } + + /** + * 官方 SDK {@code IntelligentPostClient /rpc/oauth2/dingtalk_app_user.json} + */ + private ZZDUserDTO getZzdUserByOpenPlatformAuthCode(String authCode) { + try { + String appToken = getZzdOpenAppAccessToken(); + ExecutableClient ec = zzdExecutableClientHolder.client(); + IntelligentPostClient postClient = ec.newIntelligentPostClient("/rpc/oauth2/dingtalk_app_user.json"); + OapiRpcOauth2DingtalkAppUserJsonRequest req = new OapiRpcOauth2DingtalkAppUserJsonRequest(); + req.setAccess_token(appToken); + req.setAuth_code(authCode); + OapiRpcOauth2DingtalkAppUserJsonResponse resp = postClient.post(req); + zzdAssertSdkTopSuccess(resp, "dingtalk_app_user"); + String rawContent = resp.getContent(); + log.info("浙政钉 dingtalk_app_user 原始响应 content={}", rawContent); + JSONObject bodyData = zzdSdkDingtalkAppUserContentData(rawContent); + log.info("浙政钉 dingtalk_app_user 解析后(展开嵌套前) data={}", bodyData != null ? bodyData.toJSONString() : "null"); + JSONObject data = zzdUnwrapAppUserPayload(bodyData); + log.info("浙政钉 dingtalk_app_user 展开嵌套后用户 JSON={}", data != null ? data.toJSONString() : "null"); + ZZDUserDTO dto = mapOpenPlatformUserToZzdDto(data); + if (StringUtils.hasText(zzdOpenPlatform.getTenantId()) && dto.getExt() != null + && StringUtils.hasText(dto.getExt().getRealmId()) + && !zzdOpenPlatform.getTenantId().equals(dto.getExt().getRealmId())) { + log.warn("浙政钉用户 realmId={} 与配置 zzd.open.tenant-id={} 不一致", dto.getExt().getRealmId(), zzdOpenPlatform.getTenantId()); + } + return dto; + } catch (ThirdPartyException e) { + throw e; + } catch (Exception e) { + log.error("浙政钉 dingtalk_app_user SDK 调用异常", e); + throw new ThirdPartyException("浙政钉获取用户失败", "ZZD_OPEN_USER_SDK"); + } + } + + private static void zzdAssertSdkTopSuccess(OapiResponse resp, String apiLabel) { + if (resp == null || !Boolean.TRUE.equals(resp.getSuccess())) { + String msg = resp != null && StringUtils.hasText(resp.getMessage()) ? resp.getMessage() : "success!=true"; + throw new ThirdPartyException("浙政钉 " + apiLabel + " 网关失败: " + msg, "ZZD_OPEN_FAIL"); + } + } + + private static JSONObject zzdSdkSpResultData(OapiSpResultContent content, String apiLabel) { + if (content == null) { + throw new ThirdPartyException("浙政钉 " + apiLabel + " 响应无 content", "ZZD_OPEN_BAD"); + } + if (content.getSuccess() != null && !content.getSuccess()) { + throw new ThirdPartyException("浙政钉 " + apiLabel + " 业务失败: " + content.getResponseCode() + " " + content.getResponseMessage(), "ZZD_OPEN_FAIL"); + } + String dataStr = content.getData(); + if (!StringUtils.hasText(dataStr)) { + throw new ThirdPartyException("浙政钉 " + apiLabel + " 响应无 data", "ZZD_OPEN_BAD"); + } + JSONObject data = JSON.parseObject(dataStr); + if (data == null) { + throw new ThirdPartyException("浙政钉 " + apiLabel + " data 非 JSON", "ZZD_OPEN_BAD"); + } + return data; + } + + private static JSONObject zzdSdkDingtalkAppUserContentData(String contentStr) { + if (!StringUtils.hasText(contentStr)) { + throw new ThirdPartyException("浙政钉 dingtalk_app_user 响应无 content body", "ZZD_OPEN_BAD"); + } + JSONObject root = JSON.parseObject(contentStr); + if (root == null) { + throw new ThirdPartyException("浙政钉 dingtalk_app_user content 非 JSON", "ZZD_OPEN_BAD"); + } + if (root.containsKey("success") && !root.getBooleanValue("success")) { + throw new ThirdPartyException("浙政钉 dingtalk_app_user 业务失败: " + root.getString("responseCode") + " " + root.getString("responseMessage"), "ZZD_OPEN_FAIL"); + } + JSONObject data = root.getJSONObject("data"); + if (data != null && !data.isEmpty()) { + return data; + } + JSONObject wrapped = root.getJSONObject("content"); + if (wrapped != null) { + if (wrapped.containsKey("success") && !wrapped.getBooleanValue("success")) { + throw new ThirdPartyException("浙政钉 dingtalk_app_user 业务失败: " + wrapped.getString("responseCode") + " " + wrapped.getString("responseMessage"), "ZZD_OPEN_FAIL"); + } + JSONObject inner = wrapped.getJSONObject("data"); + if (inner != null && !inner.isEmpty()) { + return inner; + } + return wrapped; + } + return root; + } + + /** + * 部分网关/版本将用户信息放在 {@code user} 或 {@code result} 下,与文档顶层字段不一致时先展开。 + */ + private static JSONObject zzdUnwrapAppUserPayload(JSONObject root) { + if (root == null) { + return null; + } + String[] nestedKeys = {"user", "result", "data", "body"}; + for (String nk : nestedKeys) { + Object inner = root.get(nk); + if (inner instanceof JSONObject) { + JSONObject jo = (JSONObject) inner; + if (!jo.isEmpty()) { + return jo; + } + } + } + return root; + } + + private static String zzdPickString(JSONObject o, String... keys) { + if (o == null || keys == null) { + return null; + } + for (String k : keys) { + if (k == null || !o.containsKey(k)) { + continue; + } + Object val = o.get(k); + if (val == null) { + continue; + } + if (val instanceof String) { + String s = ((String) val).trim(); + if (StringUtils.hasText(s)) { + return s; + } + } else { + String s = val.toString().trim(); + if (StringUtils.hasText(s)) { + return s; + } + } + } + return null; + } + + private static Integer zzdPickIntObject(JSONObject o, String... keys) { + if (o == null) { + return null; + } + for (String k : keys) { + if (!o.containsKey(k)) { + continue; + } + Object v = o.get(k); + if (v instanceof Number) { + return ((Number) v).intValue(); + } + if (v != null) { + try { + return Integer.parseInt(v.toString().trim()); + } catch (NumberFormatException ignored) { + } + } + } + return null; + } + + private static Long zzdPickLongObject(JSONObject o, String... keys) { + if (o == null) { + return null; + } + for (String k : keys) { + if (!o.containsKey(k)) { + continue; + } + Object v = o.get(k); + if (v instanceof Number) { + return ((Number) v).longValue(); + } + if (v != null) { + try { + return Long.parseLong(v.toString().trim()); + } catch (NumberFormatException ignored) { + } + } + } + return null; + } + + /** + * 将开放平台 data JSON 解析为实体,兼容驼峰/下划线及常见别名,再映射为 {@link ZZDUserDTO}。 + */ + private static ZzdDingtalkAppUserRaw parseZzdDingtalkAppUserRaw(JSONObject data) { + ZzdDingtalkAppUserRaw raw = new ZzdDingtalkAppUserRaw(); + raw.setLastName(zzdPickString(data, "lastName", "last_name")); + raw.setNickNameCn(zzdPickString(data, "nickNameCn", "nick_name_cn", "nickName", "nick_name")); + raw.setMobile(zzdPickString(data, "mobile", "phone", "telephone")); + raw.setOrganizationId(zzdPickIntObject(data, "organizationId", "organization_id", "orgId", "tenantId", "tenant_id")); + raw.setAccountId(zzdPickLongObject(data, "accountId", "account_id", "tenantUserId", "tenant_user_id")); + raw.setAccount(zzdPickString(data, "account", "loginId", "login_id", "loginid")); + String employeeCode = zzdPickString(data, "employeeCode", "employee_code", "empId", "emp_id", "jobNumber", "job_number", "staffId", "staff_id"); + if (!StringUtils.hasText(employeeCode)) { + employeeCode = raw.getAccount(); + } + raw.setEmployeeCode(employeeCode); + raw.setOpenid(zzdPickString(data, "openid", "openId", "open_id", "id")); + raw.setRealmId(zzdPickString(data, "realmId", "realm_id")); + raw.setOrganizationCode(zzdPickString(data, "organizationCode", "organization_code", "orgCode", "org_code")); + return raw; + } + + private static ZZDUserDTO mapOpenPlatformUserToZzdDto(JSONObject data) { + ZzdDingtalkAppUserRaw raw = parseZzdDingtalkAppUserRaw(data); + ZZDUserDTO dto = new ZZDUserDTO(); + String lastName = raw.getLastName(); + String nickCn = raw.getNickNameCn(); + dto.setUsername(lastName != null ? lastName : nickCn); + dto.setNickName(lastName != null ? lastName : nickCn); + dto.setMobile(raw.getMobile()); + dto.setRegionCode(null); + if (raw.getOrganizationId() != null) { + dto.setOrganizationId(raw.getOrganizationId()); + } else { + dto.setOrganizationId(0); + } + int userId = 0; + if (raw.getAccountId() != null) { + long lv = raw.getAccountId(); + if (lv <= Integer.MAX_VALUE && lv >= Integer.MIN_VALUE) { + userId = (int) lv; + } + } + dto.setUserId(userId); + ZZDUserDTO.Ext ext = dto.new Ext(); + ext.setEmployeeCode(raw.getEmployeeCode()); + ext.setOrganizationCode(raw.getOrganizationCode()); + ext.setNickNameCn(nickCn); + ext.setOpenid(raw.getOpenid()); + ext.setRealmId(raw.getRealmId()); + ext.setMobile(raw.getMobile()); + dto.setExt(ext); + return dto; + } + + + /** + * 诗画江南 token 换用户信息(与浙政钉共用网关 {@code oauth2.client.zzd-sh-user-info-url})。 + */ + public ZZDUserDTO getZZDUserInfo(String credential, String type) { // 构建请求URL try { - String url = baseUrl + config.getZzdShUserInfoUrl()+"?token=" + zzdToken; + String requestPath = config.getZzdShUserInfoUrl(); + String url = baseUrl + requestPath + "?token=" + credential; // 获取 Bearer Token String accessToken = getAccessToken().getAccess_token(); @@ -414,7 +704,7 @@ public class ThirdPartyOAuth2Service { if (response.getBody().getCode().equals("-1")){ log.error("获取浙政钉/诗画用户信息失败"); - log.error("token",zzdToken); + log.error("token",credential); log.error("response",JSON.toJSONString(response.getBody())); ThirdPartyLogEntity thirdPartyLogEntity = new ThirdPartyLogEntity(); if (type.equals(UserTypeEnum.ZZD.getType())){ @@ -423,8 +713,8 @@ public class ThirdPartyOAuth2Service { thirdPartyLogEntity.setApiName(UserTypeEnum.SH.getDescription()); } // thirdPartyLogEntity.setApiName("获取浙政钉/诗画用户信息失败"); - thirdPartyLogEntity.setRequestUrl( baseUrl + config.getZzdShUserInfoUrl()); - thirdPartyLogEntity.setRequestParameter(zzdToken); + thirdPartyLogEntity.setRequestUrl( baseUrl + requestPath); + thirdPartyLogEntity.setRequestParameter(credential); thirdPartyLogEntity.setResponseBody(JSON.toJSONString(response.getBody())); thirdPartyLogEntity.setErrorCode(response.getBody().getCode()); thirdPartyLogEntity.setErrorMsg(response.getBody().getDescription()); @@ -542,6 +832,64 @@ public class ThirdPartyOAuth2Service { } + /** + * 政务钉钉 H5 JSAPI 鉴权:前端 {@code dd.authConfig({ ticket, jsApiList })}。 + * 官方 SDK:{@code IntelligentGetClient /get_jsapi_token.json},请求体带应用 {@code accessToken},解析 {@code content.data.accessToken} 为 ticket。 + */ + public ZzdJsapiConfigVO buildZzdJsapiConfigForH5() { + if (!zzdOpenPlatform.isEnabled() || !zzdOpenPlatform.isAppCredentialConfigured()) { + throw new ThirdPartyException("浙政钉开放平台未启用或未配置 appKey/appSecret", "ZZD_JSAPI_CFG"); + } + String ticket = getZzdOpenJsapiTokenTicket(); + ZzdJsapiConfigVO vo = new ZzdJsapiConfigVO(); + vo.setTicket(ticket); + vo.setJsApiList(Arrays.asList("getAuthCode", "runtime.permission.requestAuthCode")); + return vo; + } + + private String getZzdOpenJsapiTokenTicket() { + long now = System.currentTimeMillis(); + if (StringUtils.hasText(zzdOpenCachedJsapiTicket) && now < zzdOpenCachedJsapiTicketExpireMs - 120_000L) { + return zzdOpenCachedJsapiTicket; + } + synchronized (zzdJsapiTicketLock) { + now = System.currentTimeMillis(); + if (StringUtils.hasText(zzdOpenCachedJsapiTicket) && now < zzdOpenCachedJsapiTicketExpireMs - 120_000L) { + return zzdOpenCachedJsapiTicket; + } + String appToken = getZzdOpenAppAccessToken(); + try { + ExecutableClient ec = zzdExecutableClientHolder.client(); + IntelligentGetClient getClient = ec.newIntelligentGetClient("/get_jsapi_token.json"); + OapiGetJsapiTokenJsonRequest req = new OapiGetJsapiTokenJsonRequest(); + req.setAccessToken(appToken); + OapiGetJsapiTokenJsonResponse resp = getClient.get(req); + zzdAssertSdkTopSuccess(resp, "get_jsapi_token"); + JSONObject data = zzdSdkSpResultData(resp.getContent(), "get_jsapi_token"); + String ticket = data.getString("accessToken"); + if (!StringUtils.hasText(ticket)) { + throw new ThirdPartyException("浙政钉 get_jsapi_token 响应无 data.accessToken", "ZZD_JSAPI_TK_BAD"); + } + int ttl = 7100; + try { + Object ex = data.get("expiresIn"); + if (ex != null) { + ttl = Integer.parseInt(ex.toString()); + } + } catch (NumberFormatException ignored) { + } + zzdOpenCachedJsapiTicket = ticket; + zzdOpenCachedJsapiTicketExpireMs = System.currentTimeMillis() + ttl * 1000L; + return ticket; + } catch (ThirdPartyException e) { + throw e; + } catch (Exception e) { + log.error("浙政钉 get_jsapi_token SDK 调用异常", e); + throw new ThirdPartyException("浙政钉获取 JSAPI ticket 失败", "ZZD_JSAPI_TK_SDK"); + } + } + } + /** * 诗画 * @param accountId @@ -553,7 +901,7 @@ public class ThirdPartyOAuth2Service { if (StringUtils.isEmpty(shjnToken)){ throw new RuntimeException("获取诗画江南token失败"); } - ZZDUserDTO zzdUserDTO = getZZDUserInfo(shjnToken,UserTypeEnum.SH.getType()); + ZZDUserDTO zzdUserDTO = getZZDUserInfo(shjnToken, UserTypeEnum.SH.getType()); return zzdUserDTO; } } \ No newline at end of file diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPartyZzdSmsBindService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPartyZzdSmsBindService.java new file mode 100644 index 000000000..2bc14be94 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPartyZzdSmsBindService.java @@ -0,0 +1,126 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.thirdparty.config.ThirdPartyZzdSmsBindProperties; +import com.bsd.pjcloud.thirdparty.dto.ZZDUserDTO; +import com.bsd.pjcloud.thirdparty.dto.ZzdUserSnapshot; +import com.bsd.pjcloud.thirdparty.entity.ThirdPartyZzdPhoneBindEntity; +import com.bsd.pjcloud.thirdparty.mapper.ThirdPartyZzdPhoneBindMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class ThirdPartyZzdSmsBindService { + + public static final String REDIS_BIND_SESSION_PREFIX = "thirdparty:zzd:bind:session:"; + + private final ThirdPartyZzdSmsBindProperties properties; + + private final StringRedisTemplate stringRedisTemplate; + + private final ThirdPartyZzdPhoneBindMapper thirdPartyZzdPhoneBindMapper; + + private final ObjectMapper objectMapper; + + public boolean isEnabled() { + return properties.isEnabled(); + } + + public Optional findByEmployeeCode(String employeeCode) { + if (StringUtils.isBlank(employeeCode)) { + return Optional.empty(); + } + ThirdPartyZzdPhoneBindEntity one = thirdPartyZzdPhoneBindMapper.selectOne(Wrappers.lambdaQuery() + .eq(ThirdPartyZzdPhoneBindEntity::getEmployeeCode, employeeCode) + .eq(ThirdPartyZzdPhoneBindEntity::getDelFlag, "0") + .last("limit 1")); + return Optional.ofNullable(one); + } + + public Optional findByOpenId(String openId) { + if (StringUtils.isBlank(openId)) { + return Optional.empty(); + } + ThirdPartyZzdPhoneBindEntity one = thirdPartyZzdPhoneBindMapper.selectOne(Wrappers.lambdaQuery() + .eq(ThirdPartyZzdPhoneBindEntity::getOpenId, openId) + .eq(ThirdPartyZzdPhoneBindEntity::getDelFlag, "0") + .last("limit 1")); + return Optional.ofNullable(one); + } + + /** + * 已绑定记录:优先员工编码(与库 uk 一致),再按 openid(浙政钉侧稳定标识)。 + */ + public Optional findBound(String employeeCode, String openId) { + Optional byEmp = findByEmployeeCode(employeeCode); + if (byEmp.isPresent()) { + return byEmp; + } + return findByOpenId(openId); + } + + public String createBindSession(ZzdUserSnapshot snapshot) throws JsonProcessingException { + String id = UUID.randomUUID().toString().replace("-", ""); + String key = REDIS_BIND_SESSION_PREFIX + id; + stringRedisTemplate.opsForValue() + .set(key, objectMapper.writeValueAsString(snapshot), properties.getBindSessionTtlMinutes(), TimeUnit.MINUTES); + return id; + } + + public ZzdUserSnapshot getBindSession(String bindSessionId) throws JsonProcessingException { + if (StringUtils.isBlank(bindSessionId)) { + return null; + } + String json = stringRedisTemplate.opsForValue().get(REDIS_BIND_SESSION_PREFIX + bindSessionId); + if (StringUtils.isBlank(json)) { + return null; + } + return objectMapper.readValue(json, ZzdUserSnapshot.class); + } + + public void removeBindSession(String bindSessionId) { + if (StringUtils.isBlank(bindSessionId)) { + return; + } + stringRedisTemplate.delete(REDIS_BIND_SESSION_PREFIX + bindSessionId); + } + + public void recordVerifiedBind(ZzdUserSnapshot snapshot, String verifiedPhone) throws JsonProcessingException { + upsertBindRecord(snapshot, verifiedPhone, objectMapper.writeValueAsString(snapshot)); + } + + public void upsertBindRecord(ZzdUserSnapshot snapshot, String verifiedPhone, String profileJson) { + ThirdPartyZzdPhoneBindEntity exist = thirdPartyZzdPhoneBindMapper.selectOne(Wrappers.lambdaQuery() + .eq(ThirdPartyZzdPhoneBindEntity::getEmployeeCode, snapshot.getEmployeeCode()) + .eq(ThirdPartyZzdPhoneBindEntity::getDelFlag, "0") + .last("limit 1")); + if (exist == null) { + ThirdPartyZzdPhoneBindEntity e = new ThirdPartyZzdPhoneBindEntity(); + e.setEmployeeCode(snapshot.getEmployeeCode()); + e.setOpenId(snapshot.getOpenid()); + e.setVerifiedPhone(verifiedPhone); + e.setZzdProfileJson(profileJson); + e.setDelFlag("0"); + thirdPartyZzdPhoneBindMapper.insert(e); + } + else { + exist.setVerifiedPhone(verifiedPhone); + exist.setOpenId(snapshot.getOpenid()); + exist.setZzdProfileJson(profileJson); + thirdPartyZzdPhoneBindMapper.updateById(exist); + } + } + + public static ZzdUserSnapshot snapshotOf(ZZDUserDTO dto) { + return ZzdUserSnapshot.from(dto); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/vo/ZzdJsapiConfigVO.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/vo/ZzdJsapiConfigVO.java new file mode 100644 index 000000000..1301b4030 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/vo/ZzdJsapiConfigVO.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.thirdparty.vo; + +import lombok.Data; + +import java.util.List; + +/** + * 政务钉钉 / 专有钉钉 H5:{@code dd.authConfig} 参数。 + * ticket 来自开放平台 {@code POST /get_jsapi_token.json} 返回的 {@code content.data.accessToken}(文档称鉴权 ticket)。 + */ +@Data +public class ZzdJsapiConfigVO { + + private String ticket; + + private List jsApiList; +} 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 35eae8cdb..4eb06a1d0 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml @@ -34,13 +34,24 @@ 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/ # 诗画的获取token的接口 shjn_token_url: /prod-api/login/shpj - # 浙证钉和诗画公用的获取用户信息接口 + # 诗画江南 uabaseToken 换用户信息(浙政钉单点已改走下方 zzd.open 开放平台,不再使用本 URL) zzd_sh_user_info_url: /prod-api/oauth2/apis/sso/v1/fetch_user_by_uabase_token +# 浙政钉:H5 authCode → gettoken + dingtalk_app_user(需 app-key / app-secret) +zzd: + open: + enabled: true + protocol: https + domain: openplatform-pro.ding.zj.gov.cn +# domain: openplatform.dg-work.cn + tenant-id: "196729" + app-key: ${ZZD_OPEN_APP_KEY:pjx_zhhqyth-wt2VrVa0olcqEBBJJb} + app-secret: ${ZZD_OPEN_APP_SECRET:rT5zbpHO0L7raRHJtvlIs26N8hn53JDv1K1SNa7X} + # H5 使用 dd.authConfig 时仅需 ak/sk;jsapi-agent-id 为旧版 dd.config 方案保留,可不配 + # jsapi-agent-id: + # 正元智慧 # 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 zy: @@ -188,3 +199,10 @@ hikvision: # base-url: https://api.mch.weixin.qq.com # ali-pay-config: # app-id: + +# 浙政钉无手机号时:首次短信核验 + 绑定表 third_party_zzd_phone_bind,与本系统用户手机号比对后再发 token(关闭则直接按手机号/员工编码查用户并发 token;新用户务必为 true 才会走短信页) +pjcloud: + thirdparty: + zzd-sms-bind: + enabled: true + bind-session-ttl-minutes: 15 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 822a31e0c..f51ef4d5a 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 @@ -264,4 +264,8 @@ public class SysUser extends Model implements Serializable { */ @Schema(description = "编制类型") private String staffingType; + + @Schema(description = "性别") + private Integer gender; + }