fix(auth): 修复企业微信扫码登录换 profile 必抛空指针,并给自动注册账号绑默认角色

- justauth 占位配置从 application-local.yaml 上移到 application.yaml:
AuthRequestFactory 由 @ConditionalOnProperty(matchIfMissing = true) 无条件创建,
配置缺失不等于关闭社交登录,而是请求时回落到 justauth.extend(默认 null)
直接抛 NPE,且只有 local 配过,换 dev/test 等 profile 企业微信登录必炸
- 补上 ignore-check-redirect-uri: true:redirect-uri 刻意不写死(由前端动态传、
后端用 HttpUtils.replaceUrlQuery 替换),而 JustAuth 构造 AuthRequest 时会调
AuthChecker.checkConfig,开关没开会抛 Illegal redirect uri
- AuthRequestFactory.get() 增加 extend 判空守卫,缺配置时抛可读的 AuthException,
不再让调用方拿到 500 NPE
- 企业微信扫码自动注册的账号绑定默认角色 wxcp_user:不绑的话能登录但没有菜单,
进去一片空白且不报错,很难定位。按角色编码而非编号查找,换库不会静默失效;
角色不存在时只打警告不阻断登录
- 同步更新登录联调文档的部署配置清单

Co-Authored-By: Claude <noreply@anthropic.com>
已修改5个文件
126 ■■■■■ 文件已修改
docs/wxcp_login_integration.md 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application.yaml 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/wxcp_login_integration.md
@@ -114,11 +114,30 @@
| # | 配置位置 | 内容 | 缺失后果 |
|---|----------|------|----------|
| 1 | `application-<profile>.yaml` → `wx.cp.*` | corp-id、agent-id、secret、token、aes-key | 回调接口无法解密,URL 验证失败 |
| 2 | `application-<profile>.yaml` → `justauth.type.WECHAT_ENTERPRISE` | client-id、client-secret、agent-id、redirect-uri | 调用 `social-auth-redirect` 抛 `NullPointerException` |
| 3 | 数据库 `system_social_client` | social_type=30、user_type=1、client_id=CorpId、client_secret=Secret、agent_id、status=0、deleted=0 | `social-auth-redirect` 报"社交平台(30) 不存在" |
| 2 | `application.yaml` → `justauth.type.WECHAT_ENTERPRISE`(**全局,不随 profile 变**) | client-id、client-secret、agent-id、**`ignore-check-redirect-uri: true`** | 调用 `social-auth-redirect` 抛 `AuthException: Illegal redirect uri [WECHAT_ENTERPRISE]` |
| 3 | 数据库 `system_social_client` | social_type=30、**user_type=2**、client_id=CorpId、client_secret=Secret、agent_id、status=0、deleted=0 | 只用 yaml 里的密钥,无法在后台改;填错 user_type 则该行查不到 |
**第 2 项容易漏掉**:`AuthRequestFactory` 需要先从 yaml 拿到"社交平台基类配置"才能构造出对应的 `AuthRequest` 实例,之后才会用数据库里的密钥覆盖它。所以即使密钥全都配在数据库,yaml 里也必须保留一份占位配置。
**第 2 项里 `ignore-check-redirect-uri: true` 最容易漏**。yudao 的设计是 `redirect-uri` 不写死——`SocialClientServiceImpl.getAuthorizeUrl` 最后会用 `HttpUtils.replaceUrlQuery` 把 `redirect_uri` 换成前端按当前浏览器地址传进来的值。但 JustAuth 的 `AuthDefaultRequest` 构造时会调 `AuthChecker.checkConfig(AuthConfig, AuthSource)`:
```java
if (config.isIgnoreCheckRedirectUri()) {
    return;                                    // 有这个开关才放行
}
if (StringUtils.isEmpty(redirectUri)) {
    throw new AuthException(AuthResponseStatus.ILLEGAL_REDIRECT_URI, source);
}
```
也就是说**不写 `redirect-uri` 的前提就是打开这个开关**,两者缺一不可。只删 `redirect-uri` 而漏了开关,扫码时会在 `AuthRequestFactory.getDefaultRequest` 构造 `AuthWeChatEnterpriseQrcodeRequest` 的那一刻抛 `AuthException: Illegal redirect uri [WECHAT_ENTERPRISE]`——异常发生在构造期,早于用数据库密钥覆盖的那一步,所以配置了 `system_social_client` 也救不了。
**第 2 项为什么放 `application.yaml` 而不是某个 profile**:`YudaoJustAuthConfiguration` 上的 `@ConditionalOnProperty(prefix = "justauth", value = {"enabled"}, havingValue = "true", matchIfMissing = true)` 让 `AuthRequestFactory` **无论配没配都会创建**。缺配置不等于"关闭社交登录",而是一个运行期才暴露的坑:请求走到 `AuthRequestFactory.get()` 时先按 `justauth.type` 查不到,再回落到 `justauth.extend` 分支,而 `JustAuthProperties.extend` 默认就是 `null`,直接 `NullPointerException`。
这段配置历史上只存在于 `application-local.yaml`,导致用 `dev` / `test` / `chdl` / `kyzn` / `wtxc` / `yzfx` / `zsjc` 任一 profile 启动时,企业微信扫码登录必炸,并且报的是 `AuthRequestFactory.get` 第 104 行的 NPE,很难一眼看出是配置缺失。上移到全局配置文件后,这一类问题从结构上被消除。
**代码层兜底**:`AuthRequestFactory.get()` 已加判空守卫——`properties.getExtend()` 为 null 时跳过自定义分支,由既有逻辑抛出可读的 `AuthException`,不再抛 `NullPointerException`。
### 回调 URL 配置
在企业微信管理后台 → 应用管理 → 自建应用 → 「接收消息」→「设置API接收」:
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java
@@ -99,8 +99,11 @@
        // 获取 JustAuth 中已存在的
        AuthRequest authRequest = getDefaultRequest(source);
        // 如果获取不到则尝试取自定义的
        if (authRequest == null) {
        // 如果获取不到则尝试取自定义的。
        // 注意:JustAuthProperties 的 extend 没有默认值,未配置 justauth.extend 时 getExtend() 返回 null。
        // 此时应直接跳过自定义分支,交给下方抛 AuthException(可读的"不支持的平台"),
        // 而不是在这里 getEnumClass() 上空指针,让调用方拿到一个 500 NPE。
        if (authRequest == null && properties.getExtend() != null) {
            authRequest = getExtendRequest(properties.getExtend().getEnumClass(), source);
        }
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java
@@ -16,6 +16,7 @@
import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
import cn.iocoder.yudao.module.system.convert.auth.AuthConvert;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper;
import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
@@ -26,6 +27,8 @@
import cn.iocoder.yudao.module.system.service.logger.LoginLogService;
import cn.iocoder.yudao.module.system.service.member.MemberService;
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
import cn.iocoder.yudao.module.system.service.permission.PermissionService;
import cn.iocoder.yudao.module.system.service.permission.RoleService;
import cn.iocoder.yudao.module.system.service.social.SocialUserService;
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
import com.anji.captcha.model.common.ResponseModel;
@@ -43,6 +46,7 @@
import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -62,6 +66,14 @@
     * 企业微信社交类型,对应 {@link cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum#WECHAT_ENTERPRISE}
     */
    private static final Integer WECHAT_ENTERPRISE_TYPE = 30;
    /**
     * 企业微信扫码自动注册的账号,默认绑定的角色编码。
     *
     * 需要在「系统管理 → 角色管理」里建好该编码的角色并勾选菜单,否则新账号登录后没有任何菜单。
     * 编码而非角色编号:角色编号在本地/测试/生产各库不一致,写死会在换库后静默失效。
     */
    private static final String WXCP_DEFAULT_ROLE_CODE = "wxcp_user";
    @Resource
    private AdminUserService userService;
@@ -85,6 +97,10 @@
    private PasswordEncoder passwordEncoder;
    @Resource
    private ApplicationEventPublisher eventPublisher;
    @Resource
    private RoleService roleService;
    @Resource
    private PermissionService permissionService;
    /**
     * 验证码的开关,默认为 true
@@ -267,9 +283,35 @@
        user.setStatus(CommonStatusEnum.ENABLE.getStatus());
        userMapper.insert(user);
        // 5. 绑定默认角色
        //    不绑的话账号能登录,但 get-permission-info 返回空菜单,进去一片空白;
        //    这种"能进但什么都看不到"的状态不会报错,很难定位,所以注册时就绑上
        bindDefaultRole(user);
        return user;
    }
    /**
     * 给自动注册的账号绑定默认角色
     *
     * 角色不存在时只打警告、不抛异常:一个配置遗漏不应该让扫码登录整条链路不可用,
     * 而且警告日志里已经写明了去哪儿补。
     *
     * @param user 自动注册出来的用户
     */
    private void bindDefaultRole(AdminUserDO user) {
        RoleDO role = roleService.getRoleByCode(WXCP_DEFAULT_ROLE_CODE);
        if (role == null) {
            log.warn("[bindDefaultRole][角色编码({}) 不存在,用户({}) 将没有任何菜单权限;" +
                            "请在「系统管理 → 角色管理」新建编码为 {} 的角色并分配菜单]",
                    WXCP_DEFAULT_ROLE_CODE, user.getUsername(), WXCP_DEFAULT_ROLE_CODE);
            return;
        }
        permissionService.assignUserRole(user.getId(), Set.of(role.getId()));
        log.info("[bindDefaultRole][用户({}) 已绑定默认角色({}/{})]",
                user.getUsername(), role.getCode(), role.getName());
    }
    @VisibleForTesting
    void validateCaptcha(AuthLoginReqVO reqVO) {
        ResponseModel response = doValidateCaptcha(reqVO);
yudao-server/src/main/resources/application-local.yaml
@@ -242,18 +242,5 @@
    signup-url-prefix: http://wcf.viphk.nnhk.cc
    qrcode-size: 400 # 报名二维码边长(像素),不配置默认 400
--- #################### 社交登录(JustAuth)配置 ####################
# 企业微信扫码登录走 JustAuth。
# 注意:SocialClientServiceImpl.buildAuthRequest 会先用这里的配置构造 AuthRequest,
# 再拿 system_social_client 表里同类型的记录覆盖 clientId / clientSecret / agentId。
# 所以这里必须先配一份,否则 AuthRequestFactory 取不到 WECHAT_ENTERPRISE 的配置会抛空指针。
justauth:
  enabled: true
  type:
    WECHAT_ENTERPRISE: # 企业微信扫码登录,对应 SocialTypeEnum.WECHAT_ENTERPRISE(30)
      client-id: ww2b7707c8d15ad73d
      client-secret: MhfEn_b6XfPtZHygmNEpUtecFTQgrnPgMude6jeFSsg
      agent-id: 1000003
      # redirect-uri 刻意不配:由前端按当前浏览器地址动态传,配置固定值会导致
      # 「redirect_uri 与配置的授权完成回调域名不一致」
# 注:企业微信扫码登录的 JustAuth 占位配置已上移到 application.yaml(对所有 profile 生效),
# 不在此处重复配置,避免只有 local 有、换 profile 就抛 NPE。
yudao-server/src/main/resources/application.yaml
@@ -47,7 +47,7 @@
  # 2. true: 启动时会对数据库中所有表进行更新操作,如果表存在,不做处理,反之,自动创建表
  # 3. create_drop: 启动时自动创建表,关闭时自动删除表
  # 4. drop_create: 启动时,删除旧表,再创建新表
  database-schema-update: true # 设置为 false,可通过 https://github.com/flowable/flowable-sql 初始化
  database-schema-update: false # 设置为 false,可通过 https://github.com/flowable/flowable-sql 初始化
  db-history-used: true # flowable6 默认 true 生成信息表,无需手动设置
  check-process-definitions: false # 设置为 false,禁用 /resources/processes 自动部署 BPMN XML 流程
  history-level: audit # full:保存历史数据的最高级别,可保存全部流程相关细节,包括流程流转各节点参数
@@ -196,6 +196,14 @@
  security:
    permit-all_urls:
      - /admin-api/mp/open/** # 微信公众号开放平台,微信回调接口,不需要登录
      - /admin-api/wxcp/callback/** # 企业微信回调接口,不需要登录
      - /admin-api/wxcp/activity/signup-page # 活动报名落地页,扫码打开,不需要登录
      - /admin-api/wxcp/activity/signup # 活动报名提交,不需要登录
      # 报名页需要上传图片/附件,而 system 的上传接口默认要求登录。
      # 这里只放开「公共上传」这一个已有接口(不新建 upload 接口),
      # 注意:放开后任何能访问到该地址的人都可以上传文件,属于有意的权衡。
      # 如需收紧,可改为让报名页先走企业微信网页授权拿令牌后再上传。
      - /admin-api/system/storage-blob/public-upload # 活动报名页附件上传
  api-encrypt:
    enable: true # 是否开启 API 加密
    algorithm: AES # 加密算法,支持 AES、RSA 等
@@ -275,4 +283,31 @@
    message-bus:
      type: redis # 消息总线的类型
--- #################### 社交登录(JustAuth)配置 ####################
# 企业微信扫码登录的「占位配置」,刻意放在 application.yaml 而非某个 profile,
# 以保证所有 profile 都有,不会再出现「只有 local 配了、换 profile 就抛 NPE」。
#
# 为什么必须存在:SocialClientServiceImpl.buildAuthRequest 会先用 yaml 里的配置构造 AuthRequest,
# 再拿 system_social_client 表里同类型的记录覆盖 clientId / clientSecret / agentId。
# 而 YudaoJustAuthConfiguration 的 @ConditionalOnProperty(matchIfMissing = true)
# 让 AuthRequestFactory 无论配没配都会创建,一旦这里缺失,调用链会回落到
# justauth.extend 分支,而 extend 默认为 null,直接抛 NullPointerException。
#
# 注意:下面这些值只是让 AuthRequestFactory 能构造出 AuthWeChatEnterpriseQrcodeRequest 的占位,
# 真正生效的凭据来自数据库 system_social_client(social_type=30、user_type=1)。
justauth:
  enabled: true
  type:
    WECHAT_ENTERPRISE: # 企业微信扫码登录,对应 SocialTypeEnum.WECHAT_ENTERPRISE(30)
      client-id: ww2b7707c8d15ad73d
      client-secret: MhfEn_b6XfPtZHygmNEpUtecFTQgrnPgMude6jeFSsg
      agent-id: 1000003
      # redirect-uri 不配:由前端按当前浏览器地址动态传,SocialClientServiceImpl.getAuthorizeUrl
      # 会用 HttpUtils.replaceUrlQuery 把 redirect_uri 换成前端传进来的值。
      # 所以必须同时打开下面这个开关 —— JustAuth 的 AuthDefaultRequest 构造时会调
      # AuthChecker.checkConfig,redirectUri 为空且未忽略校验时直接抛
      # AuthException: Illegal redirect uri [WECHAT_ENTERPRISE]。
      ignore-check-redirect-uri: true
debug: false