diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CacheConstants.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CacheConstants.java index de3784cad..77c915dec 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CacheConstants.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CacheConstants.java @@ -137,6 +137,11 @@ public interface CacheConstants { */ String WX_PC_SOCIAL_TICKET = GLOBALLY + "wx:pc:social:ticket:"; + /** + * PC 钉钉社交登录一次性 ticket(openId 缓存) + */ + String DINGTALK_SOCIAL_TICKET = GLOBALLY + "dingtalk:social:ticket:"; + /** * App Apple 社交登录一次性 ticket(sub 缓存) */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysSocialDetailsController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysSocialDetailsController.java index 99ef55e1f..d844b64d4 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysSocialDetailsController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysSocialDetailsController.java @@ -216,6 +216,24 @@ public class SysSocialDetailsController { return R.ok(pcWechatSocialService.loginByWxPcTicket(ticket)); } + /** + * PC 钉钉:解析 code,返回绑定状态与 ticket + */ + @Inner(value = false) + @PostMapping("/pc/dingtalk/resolve") + public R resolveDingTalkCode(String code) { + return R.ok(pcWechatSocialService.resolveDingTalkCode(code)); + } + + /** + * PC 钉钉已绑定:凭 ticket 直接登录 + */ + @Inner(value = false) + @PostMapping("/pc/dingtalk/login-by-ticket") + public R> loginPcByDingTalkTicket(String ticket) { + return R.ok(pcWechatSocialService.loginByDingTalkTicket(ticket)); + } + /** * 导出 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/DingTalkLoginHandler.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/DingTalkLoginHandler.java index cd4201af4..edc9f584c 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/DingTalkLoginHandler.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/DingTalkLoginHandler.java @@ -1,24 +1,9 @@ -/* - * 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.admin.handler; +import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpUtil; +import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; @@ -28,15 +13,13 @@ import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.mapper.SysSocialDetailsMapper; import com.bsd.pjcloud.admin.service.SysUserService; import com.bsd.pjcloud.common.core.constant.enums.LoginTypeEnum; +import com.bsd.pjcloud.common.core.exception.CheckedException; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; /** - * @author pjcloud - * @date 2023-10-14 - * - * 钉钉登录 + * 钉钉登录(PC 扫码 / 个人中心绑定) */ @Slf4j @Component("DINGTALK") @@ -48,48 +31,55 @@ public class DingTalkLoginHandler extends AbstractLoginHandler { private final SysSocialDetailsMapper sysSocialDetailsMapper; /** - * QQ登录传入code - *

- * 通过code 调用qq 获取唯一标识 - * @param code - * @return + * 用授权 code 换取钉钉 openId */ @Override public String identify(String code) { + if (StrUtil.isBlank(code)) { + throw new CheckedException("钉钉授权码为空"); + } SysSocialDetails condition = new SysSocialDetails(); condition.setType(LoginTypeEnum.DINGTALK.getType()); SysSocialDetails socialDetails = sysSocialDetailsMapper.selectOne(new QueryWrapper<>(condition)); + if (socialDetails == null) { + throw new CheckedException("未配置钉钉登录,请在后台三方账号中添加 type=DINGTALK 的应用"); + } - String getAccessTokenUrl = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"; - String accessTokenResult = HttpUtil.post(getAccessTokenUrl, + String accessTokenResult = HttpUtil.post("https://api.dingtalk.com/v1.0/oauth2/userAccessToken", JSONUtil.createObj() - .set("clientId", socialDetails.getAppId()) - .set("clientSecret", socialDetails.getAppSecret()) - .set("grantType", "authorization_code") - .set("code", code) - .toString()); + .set("clientId", socialDetails.getAppId()) + .set("clientSecret", socialDetails.getAppSecret()) + .set("grantType", "authorization_code") + .set("code", code) + .toString()); log.debug("获取钉钉Token响应报文:{}", accessTokenResult); - String accessToken = JSONUtil.parseObj(accessTokenResult).getStr("accessToken"); - String getUserIdUrl = "https://api.dingtalk.com/v1.0/contact/users/me"; + JSONObject tokenJson = JSONUtil.parseObj(accessTokenResult); + String accessToken = tokenJson.getStr("accessToken"); + if (StrUtil.isBlank(accessToken)) { + String errMsg = StrUtil.blankToDefault(tokenJson.getStr("message"), + StrUtil.blankToDefault(tokenJson.getStr("errmsg"), "钉钉授权失败")); + log.error("钉钉换取 accessToken 失败: {}", accessTokenResult); + throw new CheckedException("钉钉授权失败:" + errMsg); + } - String userResult = HttpRequest.get(getUserIdUrl) - .header("x-acs-dingtalk-access-token", accessToken) - .execute() - .body(); - log.debug("获取钉钉UserId响应报文:{}", userResult); - // 企微唯一标识 - return JSONUtil.parseObj(userResult).getStr("openId"); + String userResult = HttpRequest.get("https://api.dingtalk.com/v1.0/contact/users/me") + .header("x-acs-dingtalk-access-token", accessToken) + .execute() + .body(); + log.debug("获取钉钉用户响应报文:{}", userResult); + JSONObject userJson = JSONUtil.parseObj(userResult); + String openId = userJson.getStr("openId"); + if (StrUtil.isBlank(openId)) { + String errMsg = StrUtil.blankToDefault(userJson.getStr("message"), "未获取到钉钉用户标识"); + log.error("钉钉获取 openId 失败: {}", userResult); + throw new CheckedException("钉钉授权失败:" + errMsg); + } + return openId; } - /** - * openId 获取用户信息 - * @param openId - * @return - */ @Override public UserInfo info(String openId) { SysUser user = sysUserService.getOne(Wrappers.query().lambda().eq(SysUser::getWxDingUserid, openId)); - if (user == null) { log.info("钉钉未绑定:{}", openId); return null; @@ -97,12 +87,6 @@ public class DingTalkLoginHandler extends AbstractLoginHandler { return sysUserService.findUserInfo(user); } - /** - * 绑定逻辑 - * @param user 用户实体 - * @param identify 渠道返回唯一标识 - * @return - */ @Override public Boolean bind(SysUser user, String identify) { user.setWxDingUserid(identify); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/PcWechatSocialService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/PcWechatSocialService.java index 011649ff3..8a97591b8 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/PcWechatSocialService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/PcWechatSocialService.java @@ -3,7 +3,7 @@ package com.bsd.pjcloud.admin.service; import java.util.Map; /** - * PC 后台微信:密码登录后的绑定 / 扫码确认 + * PC 后台社交登录:微信绑定/确认;钉钉扫码登录 */ public interface PcWechatSocialService { @@ -27,4 +27,14 @@ public interface PcWechatSocialService { */ Map loginByWxPcTicket(String ticket); + /** + * 解析钉钉 code,返回绑定状态与 ticket + */ + com.bsd.pjcloud.admin.api.vo.WxSocialResolveVO resolveDingTalkCode(String code); + + /** + * 钉钉已绑定:凭 resolve 下发的 ticket 直接登录 + */ + Map loginByDingTalkTicket(String ticket); + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/PcWechatSocialServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/PcWechatSocialServiceImpl.java index 151d604f3..14aa26c00 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/PcWechatSocialServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/PcWechatSocialServiceImpl.java @@ -63,6 +63,10 @@ public class PcWechatSocialServiceImpl implements PcWechatSocialService { return CacheConstants.PC_LOGIN_CONFIRM + ticket; } + private String dingTalkTicketKey(String ticket) { + return CacheConstants.DINGTALK_SOCIAL_TICKET + ticket; + } + @Override public WxSocialResolveVO resolveWxPcCode(String code) { LoginHandler wxPcHandler = loginHandlerMap.get("WX_PC"); @@ -76,6 +80,40 @@ public class PcWechatSocialServiceImpl implements PcWechatSocialService { return vo; } + @Override + public WxSocialResolveVO resolveDingTalkCode(String code) { + LoginHandler dingHandler = loginHandlerMap.get("DINGTALK"); + if (dingHandler == null) { + throw new CheckedException("未启用钉钉登录"); + } + String openId = dingHandler.identify(code); + UserInfo userInfo = dingHandler.info(openId); + String ticket = IdUtil.simpleUUID(); + redisTemplate.opsForValue().set(dingTalkTicketKey(ticket), openId, TICKET_TTL_MINUTES, TimeUnit.MINUTES); + WxSocialResolveVO vo = new WxSocialResolveVO(); + vo.setBound(userInfo != null); + vo.setTicket(ticket); + return vo; + } + + @Override + public Map loginByDingTalkTicket(String ticket) { + if (StrUtil.isBlank(ticket)) { + throw new CheckedException("钉钉授权已过期,请重新扫码"); + } + String openId = redisTemplate.opsForValue().get(dingTalkTicketKey(ticket)); + if (StrUtil.isBlank(openId)) { + throw new CheckedException("钉钉授权已过期,请重新扫码"); + } + redisTemplate.delete(dingTalkTicketKey(ticket)); + SysUser user = sysUserService.getOne(Wrappers.lambdaQuery().eq(SysUser::getWxDingUserid, openId)); + if (user == null) { + throw new CheckedException("该钉钉未绑定 PC 后台账号,请先用账号密码登录并绑定"); + } + cacheManager.getCache(CacheConstants.USER_DETAILS).evict(user.getUsername()); + return issueToken(user); + } + @Override public Map bindByPwdTicket(String pwdTicket, String code) { String userIdStr = redisTemplate.opsForValue().get(pwdVerifiedKey(pwdTicket)); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java index 97d7b1405..d72b5816c 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java @@ -268,10 +268,9 @@ public class SysFileServiceImpl extends ServiceImpl impl SysFile sysFile = new SysFile(); sysFile.setFileName(fileName); - // 对原始文件名进行编码转换 - String originalFilename = new String( - Objects.requireNonNull(file.getOriginalFilename()).getBytes(StandardCharsets.ISO_8859_1), - StandardCharsets.UTF_8); + // 对原始文件名做安全解析:Spring Boot 3 / Undertow 已按 UTF-8 处理, + // 仅对「UTF-8 字节被误按 ISO-8859-1 解码」的旧容器乱码做兼容转码。 + String originalFilename = resolveOriginalFilename(file); sysFile.setDir(dir); sysFile.setOriginal(originalFilename); sysFile.setFileSize(file.getSize()); @@ -290,10 +289,7 @@ public class SysFileServiceImpl extends ServiceImpl impl SysFile sysFile = new SysFile(); sysFile.setFileName(fileName); - // 对原始文件名进行编码转换 - String originalFilename = new String( - Objects.requireNonNull(file.getOriginalFilename()).getBytes(StandardCharsets.ISO_8859_1), - StandardCharsets.UTF_8); + String originalFilename = resolveOriginalFilename(file); sysFile.setDir(dir); sysFile.setOriginal(originalFilename); sysFile.setFileSize(file.getSize()); @@ -309,4 +305,42 @@ public class SysFileServiceImpl extends ServiceImpl impl return sysFile; } + /** + * 解析上传原始文件名。 + *

+ * 当前运行环境(Spring Boot 3 + Undertow)multipart 文件名已是 UTF-8。 + * 旧写法强制 ISO-8859-1→UTF-8 会把合法中文转成 {@code ?}; + * 仅当文件名看起来像 Latin-1 误解码乱码时才尝试转码。 + */ + private String resolveOriginalFilename(MultipartFile file) { + String original = file.getOriginalFilename(); + if (StrUtil.isBlank(original)) { + return "unknown"; + } + // 已含 CJK 等非 Latin-1 字符,说明容器已正确解码,直接使用 + if (containsNonLatin1(original)) { + return original; + } + // 仅 ASCII:无需转码 + if (original.chars().allMatch(c -> c <= 0x7F)) { + return original; + } + // 疑似 UTF-8 字节被按 ISO-8859-1 解码,尝试还原 + try { + String converted = new String(original.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8); + if (StrUtil.isNotBlank(converted) && !converted.contains("\uFFFD") && containsNonLatin1(converted)) { + return converted; + } + } + catch (Exception ignored) { + // 转码失败则保留原始值 + } + return original; + } + + /** 是否包含超出 ISO-8859-1 单字节范围的字符(如中文) */ + private boolean containsNonLatin1(String value) { + return value.chars().anyMatch(c -> c > 0xFF); + } + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysSocialDetailsServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysSocialDetailsServiceImpl.java index d856d5677..32f62a071 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysSocialDetailsServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysSocialDetailsServiceImpl.java @@ -73,6 +73,9 @@ public class SysSocialDetailsServiceImpl extends ServiceImpl { SysSocialDetailsVO newSysSocialDetails = new SysSocialDetailsVO(); BeanUtils.copyProperties(sysSocialDetails, newSysSocialDetails); - if ("WX_PC".equals(sysSocialDetails.getType())) { + if ("WX_PC".equals(sysSocialDetails.getType()) || "DINGTALK".equals(sysSocialDetails.getType())) { newSysSocialDetails.setRedirectUrl(normalizeWxPcRedirectUrl(sysSocialDetails.getRedirectUrl())); } return newSysSocialDetails; }).collect(Collectors.toList()); } - /** PC 微信 qrconnect:redirect_uri 不能含 # */ + /** PC 微信 / 钉钉 OAuth:redirect_uri 不能含 # */ private static String normalizeWxPcRedirectUrl(String redirectUrl) { if (StrUtil.isBlank(redirectUrl)) { return redirectUrl;