Browse Source

feat: 钉钉组织同步端点 + 管理员角色配置接口——dt-java 部门树/HTTP listbypage 用户幂等同步,dt_dept_id 与 account 作幂等键,负责人反写不映射角色(frontend-integration 票据 03)

master
luoweijian 3 weeks ago
parent
commit
e4b2a320ea
  1. 32
      .scratch/frontend-integration/issues/01-region-seed.md
  2. 35
      .scratch/frontend-integration/issues/02-dict-seed-align.md
  3. 37
      .scratch/frontend-integration/issues/03-dingtalk-sync-endpoint.md
  4. 80
      .scratch/frontend-integration/issues/04-dtjava-sdk-research.md
  5. 32
      .scratch/frontend-integration/issues/05-testdata-seeding.md
  6. 19
      .scratch/frontend-integration/issues/06-static-demo.md
  7. 21
      .scratch/frontend-integration/issues/07-api-audit-against-prototype.md
  8. 45
      .scratch/frontend-integration/map.md
  9. 266
      .scratch/frontend-integration/seed-testdata.py
  10. 14
      crm-auth/pom.xml
  11. 36
      crm-auth/src/main/java/com/crm/auth/config/DtClientConfig.java
  12. 6
      crm-auth/src/main/java/com/crm/auth/constant/AuthConstants.java
  13. 29
      crm-auth/src/main/java/com/crm/auth/controller/SystemController.java
  14. 29
      crm-auth/src/main/java/com/crm/auth/domain/dto/ConfigureAdminRoleDTO.java
  15. 31
      crm-auth/src/main/java/com/crm/auth/domain/dto/DtSyncResultDTO.java
  16. 8
      crm-auth/src/main/java/com/crm/auth/domain/entity/SysDept.java
  17. 12
      crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java
  18. 19
      crm-auth/src/main/java/com/crm/auth/service/IDtOrgSyncService.java
  19. 51
      crm-auth/src/main/java/com/crm/auth/service/impl/AuthUserServiceImpl.java
  20. 523
      crm-auth/src/main/java/com/crm/auth/service/impl/DtOrgSyncServiceImpl.java
  21. 2
      crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java
  22. 78
      crm-auth/src/test/java/com/crm/auth/service/impl/AuthUserServiceImplTest.java
  23. 273
      crm-auth/src/test/java/com/crm/auth/service/impl/DtOrgSyncServiceImplTest.java
  24. 2
      crm-auth/src/test/java/com/crm/auth/service/impl/UserListIntegrationTest.java

32
.scratch/frontend-integration/issues/01-region-seed.md

@ -0,0 +1,32 @@
# 行政区划全量种子导入
Label: wayfinder:task
Type: task
Status: resolved
Blocked by: (无)
## Question
在 crm-rule 实现行政区划 seeder(与 crm-dict 的 `DictDataInitializer` 同款 CommandLineRunner 幂等模式),把国标 GB/T 2260 省/市/区三级全量数据(约 3200 行)导入 `sys_region` 表,使 `/api/rule/region/list``/api/rule/region/level` 级联下拉有完整数据。
要点:
- 数据源:民政部/国家统计局官方区划 CSV(需获取最新年度全量数据,内嵌到 resources 或构建期拉取)
- 幂等:重复执行不重复插入;code 唯一
- `parentId` 正确挂载三级树;直辖市的市辖区层级按国标处理
- 注意 `.java` 文件 UTF-8 无 BOM 规范(见 AGENTS.md)
## Answer
已完成(编译 + 单测通过):
**数据**:`crm-rule/src/main/resources/data/pca-code.json`(china-division 2.7.0 npm 包,国家统计局口径)——34 省(31 大陆 + 港澳台省级 810000/820000/710000)/ 342 市 / 3056 区县镇街,共 3432 行。GitHub raw 限流,改走 npmmirror tgz 下载。
**实现**:`RegionDataInitializer`(`@Order(12)` CommandLineRunner,与 DictDataInitializer 同款模式):
- 编码规范化:省/市/区补零到 6 位国标码("11"→"110000");东莞/中山等直辖县级市的镇街 9 位码补零到 12 位(与实体注释「6 位或 12 位」对齐),前缀嵌套不破
- 幂等:每层整体走 `batchUpsert`(INSERT ... ON DUPLICATE KEY UPDATE,命中 uk_region_code),存在则刷新名称、不存在则插入,永不删除
- 父子挂接分三层「upsert → 回查拿 id → 下一层」(upsert 不回填主键),省级 parentId=null
- 顺手修正:`ISysRegionService` 改为继承 `IBaseService<SysRegion>`(原本违反「所有业务 Service 必须继承本接口」约定,且借此获得 batchUpsert 能力)
**验证**:`RegionDataInitializerTest` 4 个用例(数据文件完整性/港澳台/补零规则/实体构建)全部通过;全量编译通过。本地 `:8081` 连 8.129.84.155 开发库两次启动实测:首次「存量 0 行,新增 3432 行」,二次「存量 3432 行,新增 0 行」——写入与幂等均已验证。
**构建环境备注**:本机无 mvn/PATH JDK,已在 `tmp/apache-maven-3.9.9` 解压 Maven,JDK 用 `~/.jdks/ms-17.0.18`(JAVA_HOME 需显式设置)。

35
.scratch/frontend-integration/issues/02-dict-seed-align.md

@ -0,0 +1,35 @@
# 字典种子对齐原型
Label: wayfinder:task
Type: task
Status: resolved
Blocked by: (无)
## Question
修改 crm-dict `DictDataInitializer``SEED_GROUPS`,使新增线索表单的 4 个字典类下拉有数据,选项全部取自蓝湖原型(`.scratch/lanhu-text/a2-1-2-2____.txt` 等):
1. `lead_source`(渠道)——现有选项(广告投放/转介绍/展会活动/官网咨询/线下拜访/其他)与原型渠道清单(展会/知乎/数字音视/…)对不上,**替换为原型实际枚举**
2. 新增 `brand`(品牌)——原型仅「itc」一项,照原样只种一项
3. 新增 `product`(需求产品)——LED 显示屏等原型清单
4. 新增 `scene`(需求场景)——会议室/报告厅/指挥中心/文体场馆/剧场剧院/酒吧KTV/高职教/普教/公检法司/医疗/国防部队/政府单位/交通/金融/能源/园区/文旅/展厅(以原型实际清单为准核对)
约束:
- `customer_status`、`industry` 两个现有种子保留不动
- seeder「只加不删」,旧环境的 lead_source 残留项由联调库重建或手工停用,不写迁移逻辑
- 同步核对:线索表单字段引用的分组编码与 crm-lead 保存/回显逻辑中的字典编码一致
## Answer
已完成(单测 7/7 通过 + 开发库启动实测):
**种子对齐原型 A2-1-2-2**(`DictDataInitializer.SEED_GROUPS`,共 6 组 92 项):
- `lead_source`:改名「渠道」,旧 6 项替换为原型渠道清单 38 项(展会/数字音视/京东/…/头条);原型中的占位文本「列表项1」判定为蓝湖 mock 残留,不入库
- `brand`(品牌)新增 9 项:原型实际清单为 itc/世畅/丽声/CRX/3A/北航星/特控/SOOANN/金业诚兴(票据描述“仅 itc 一项”与原型不符,以原型实际清单为准)
- `product`(需求产品)新增 16 项:LED显示屏/…/安防监控
- `scene`(需求场景)新增 18 项:会议室/…/展厅(与票据清单一致)
- `customer_status`/`industry` 原样保留
**编码对齐**:crm-lead `LeadConstants.DICT_GROUP_*` 原为点号编码(lead.channel 等,违反 crm-dict 编码规则且尚未接线),改为 `lead_source`/`brand`/`product`/`scene`;`Lead` 实体字段注释同步更新。全库无残留点号引用。
**验证**:`DictDataInitializerTest` 断言同步更新(6 组/92 项/用户分组跳过 54 项等),7 用例全过;本地 `:8081` 连开发库启动实测:存量 lead_source 组改名为「渠道」、exhibition 项名刷为「展会」、其余新项与 3 个新分组全部创建,「只加不删」语义生效(旧项 ad/referral/website/offline/other 残留,待联调库重建或手工停用,未写迁移逻辑)。

37
.scratch/frontend-integration/issues/03-dingtalk-sync-endpoint.md

@ -0,0 +1,37 @@
# 钉钉组织同步端点与管理员角色配置接口
Label: wayfinder:task
Type: task
Status: resolved
Blocked by: 04
## Question
在 crm-auth 实现两个接口,为联调铺设真实用户/组织数据:
1. **钉钉组织同步端点**(如 `POST /api/system/sync/dingtalk`,超管权限):
- 引入 `com.github.tingyugetc520:dt-java:0.1.2` 依赖(用法结论见票据 04 的研究成果)
- 复用 application.yml 现有钉钉配置与企业级 access token 能力(`DingTalkAuthClient.getCorpAccessToken`)
- 拉取部门树 → `sys_dept`;拉取部门下用户 → `auth_user`(姓名/手机号/钉钉 userid 作 account/部门挂载)
- **部门负责人是钉钉字段**(dept_manager_userid),随部门存入,不映射为系统角色
- 幂等 upsert:重跑不重复,钉钉侧改名可更新
2. **管理员角色配置接口**:给指定用户分配/回收管理员类角色(部门负责人/销售角色仍由角色管理体系承载,此接口只解决「同步进来的用户如何拿到角色」)
验收:同步全量组织后,用户列表/部门树接口能查到真实数据,角色配置接口可给 2~3 个用户配上角色。
## Answer
两个接口均落在 crm-auth,测试全绿(`DtOrgSyncServiceImplTest` 3/3、`AuthUserServiceImplTest` 11/11,排除 crm-lead 既有失败后全量 EXIT=0)。
1. **钉钉组织同步** `POST /api/system/sync/dingtalk`(`@PreAuthorize("hasRole('ADMIN')")`,`SystemController`):
- `DtOrgSyncServiceImpl`:部门链路用 dt-java SDK(`DtDepartmentService.list(null, true)`),用户链路因 dt-java 0.1.2 发布版 `DtUser``isLeaderInDeparts` 改为 Hutool HTTP 直调 `/topapi/user/listbypage`(与 `DingTalkAuthClient` 同款手法,分页 100 + 100ms 限流);企业 token 共享 Redis key `crm:auth:dingtalk:corp-token`
- 幂等键:`sys_dept.dt_dept_id`(新增字段 + 唯一索引 `uk_dt_dept_id`)匹配部门;`crm_auth_user.account`=钉钉 userid 匹配用户;unionId 写 `crm_auth_identity` 保证扫码登录映射。
- BFS 排序保证父部门先落库;主/兼职部门拆分(首个出现部门为主部门,其余 `sys_user_dept` 全量替换);`isLeaderInDepts` 聚合反写 `sys_dept.leader_user_id`(负责人只是部门字段,不映射角色);离职用户置 `employment_status=resigned` 不动 enabled;AtomicBoolean 防并发重入。
- 返回 `DtSyncResultDTO` 统计(部门/用户增改数、负责人反写数);失败统一错误码 61017 `CODE_DINGTALK_SYNC_FAIL`
2. **管理员角色配置** `POST /api/system/users/admin-role`(`@PreAuthorize("hasRole('ADMIN')")`):
- `IAuthUserService.configureAdminRoles(userId, roleCodes, grant)`:增量授予/回收,只动目标角色行(授予幂等、回收无则忽略),不影响其他角色;`roleCodes` 缺省 `ROLE_ADMIN`(`AuthConstants.ROLE_CODE_ADMIN`)。
- 校验顺序:userId null → codes 空 → 用户存在 → 角色编码存在;DTO 为 `ConfigureAdminRoleDTO`(ADR-0017 风格)。
已知边界:crm-lead `LeadServiceImplTest` 3 个「省份不能为空」失败为既有 committed 问题(必填误校验),归票据 07 盘点范围;`syncOrg()` 整体包在单个 `@Transactional` 内(含 HTTP 拉取),大规模组织下事务较长,若联调遇连接池/超时问题再拆小事务。
**真实同步验证挂起(用户确认「先留着」)**:端点链路已实测跑通(本地 :8081 verify profile,调试 token 见 `tmp/dt-token.txt`),但钉钉侧返回 errcode=60011——应用 `dingikqv3al2koirnku7` 未开通 `qyapi_get_department_list`(部门读)与 `qyapi_get_member`(成员读)权限。权限就绪后直接重试 `POST /api/system/sync/dingtalk` 即可,无需改代码。

80
.scratch/frontend-integration/issues/04-dtjava-sdk-research.md

@ -0,0 +1,80 @@
# dt-java SDK 部门与用户拉取 API 调研
Label: wayfinder:research
Type: research
Status: resolved
Blocked by: (无)
## Question
调研第三方库 `com.github.tingyugetc520:dt-java:0.1.2`(钉钉 OpenAPI 的 Java SDK,钉钉官方 dingtalk-java 的社区分支)的用法,产出票据 03 实现所需的 API 结论:
1. 如何用 Client ID/Client Secret(企业内部应用凭证)初始化 client 并获取企业级 access token
2. 拉取部门树列表的 API(部门 ID/名称/父部门/负责人 dept_manager_userid 字段)
3. 按部门拉取用户详情列表的 API(姓名/手机号/unionId/userid 字段),分页方式
4. 与现有 `DingTalkAuthClient` 手写 HTTP + Hutool 方式的关系:是否直接用 SDK 替换手写调用,还是仅在同步端点内使用 SDK(倾向后者,不动已验证的登录链路)
5. Maven 仓库可用性(中央仓库是否有 0.1.2 版本)与传递依赖冲突风险
产出:一页结论(含关键类名、调用示例、API 端点对照),供票据 03 直接照抄实现。
## Answer
调研完成(源码已克隆到 `tmp/DtJava/` 可直接查阅)。结论如下:
### 1. 可用性与形态
- Maven Central 确认存在 `com.github.tingyugetc520:dt-java:0.1.2`(2021-03-08 发布)。封装的是**旧版 oapi.dingtalk.com** 接口(非新版 api.dingtalk.com),5 年未更新但旧版接口仍在服务。
### 2. 初始化(凭证)
```java
DtDefaultConfigImpl config = new DtDefaultConfigImpl();
config.setCorpId("xxx"); // ⚠️ yml 目前没有,需新增配置
config.setAgentId(123L); // ⚠️ yml 目前没有,需新增配置
config.setAppKey(clientId); // = 现有 crm.auth.dingtalk.client-id
config.setAppSecret(clientSecret); // = 现有 client-secret
DtServiceImpl dtService = new DtServiceImpl();
dtService.setDtConfigStorage(config);
// SDK 自管企业 token(旧版 gettoken 接口,内部自动刷新)
```
包名根:`com.github.tingyugetc520.ali.dingtalk.*`(注意不是 com.github.....dtjava)。
### 3. 部门树:`DtDepartmentService`
```java
List<DtDepart> depts = dtService.getDepartmentService().list(null, true); // 递归全量
```
`DtDepart` 字段仅 `id / name / parentId / order`——**没有 dept_manager_userid**。
### 4. 部门负责人:从用户侧反查(关键修正)
部门负责人不在 DtDepart,而在 `DtUser.isLeaderInDeparts: Map<deptId, Boolean>`(获取部门用户详情接口返回)。同步逻辑应为:拉用户时聚合 `isLeaderInDeparts`,反写对应部门的负责人字段。
### 5. 用户:`DtUserService`
```java
// 部门用户详情(offset/size 分页,循环拉到不足一页为止)
List<DtUser> users = dtService.getUserService().listByDepartment(deptId, offset, size, null);
// 单个详情
DtUser u = dtService.getUserService().getById(userId);
```
`DtUser` 关键字段:`userId / unionId / name / mobile / position / jobNumber / email / avatar / departIds(多部门) / active / isAdmin / isLeaderInDeparts / hiredDate`——同步到 `auth_user`(account=userId,username=name,mobile,部门挂载 departIds,在职状态可看 active)完全够用。
### 6. 与现有 DingTalkAuthClient 的关系
SDK 用旧版 oapi + 自管 token;现有登录链路用新版 api.dingtalk.com + 手写 HTTP。两套并存无冲突,**同步端点单独用 SDK,不动已验证的登录链路**(维持倾向结论)。
### 7. 传递依赖风险(需在票据 03 处理)
pom 传递依赖较旧:okhttp 4.5.0、httpclient、guava 29.0-jre、gson 2.8.0、**xstream 1.4.15(有已知 CVE,仅事件回调 XML 反序列化用到,同步链路不触碰)**、commons-io 2.5、commons-lang3 3.10。与 Spring Boot 3 BOM 管理的版本存在降级竞争(Maven 最近优先,Boot BOM 在父 POM 通常胜出)。建议:引入时对 xstream、guava、gson 做 exclusions 或显式锁定 Boot 版本,验证 `mvn dependency:tree` 无降级后再用。
### 给票据 03 的落地清单
1. yml `crm.auth.dingtalk` 新增 `corp-id`、`agent-id` 两项(用户提供值)
2. crm-auth pom 引 dt-java(带 exclusions)
3. 同步:`list(null,true)` 拉部门树 → sys_dept(parentId 挂树);逐部门 `listByDepartment` 分页拉用户 → auth_user;聚合 `isLeaderInDeparts` 反写部门负责人
4. 部门循环注意钉钉限流(建议每请求间隔 ~100ms,或并发 2~3 线程 + 限流)

32
.scratch/frontend-integration/issues/05-testdata-seeding.md

@ -0,0 +1,32 @@
# 公海池与线索种子数据铺设
Label: wayfinder:task
Type: task
Status: resolved
Blocked by: 01, 02, 03
## Question
在前置数据就绪(区划、字典、真实用户/组织)后,通过后端 API 铺设线索业务测试数据,让联调一打开列表页就有数据:
1. **公海池 ×2**:挂不同部门、不同省份(走 `/api/rule/pool/saveOrUpdate`),各配负责人与领取规则
2. **线索种子**:覆盖多种状态(待领取/已领取/跟进中/已转商机/过期失效),分布在两个池与不同销售名下,字段取值用原型示例(如「网络奇迹有限公司 / 15611229615 / 广东省 广州市 / 展会 / itc / LED显示屏」)
3. 铺设脚本固化(幂等可重跑),放 `.scratch/frontend-integration/` 或随 Bruno 集合
4. 顺带验证:铺设过程中真实跑一遍公海池与线索写接口,发现的阻塞问题即时记录
验收:四列表页面(线索公海/我的线索/我的关注/线索管理)打开均有分页数据,各状态标签可见。
## Answer
铺设脚本固化为 `seed-testdata.py`(本目录,幂等可重跑,纯 API 铺设,实测重跑零重复):`py seed-testdata.py`(默认 :8081,`SEED_BASE`/`--base` 可切测试服务器,`--user` 指定操作人)。
1. **公海池 ×2**(`/api/rule/pool/saveOrUpdate`):华南公海池(seed)(华南销售部(seed),广东/广州,规则1成员可领)+ 华北公海池(seed)(存量部门,北京,规则2仅管理员分配),负责人均为存量管理员用户。受「一部门一池」约束,脚本幂等创建了第二个部门「华南销售部(seed)」。
2. **线索种子 ×5**(`/api/lead/create` + 流转端点):网络奇迹有限公司(待领取,原型示例值,展会/itc/LED显示屏)/ 广州智联(已领取)/ 深圳华彩(跟进中,反馈提交)/ 东莞声视(目标转商机,被阻塞见发现项)/ 北京京彩(待领取,华北池)。另关注 2 条使「我的关注」有数据。
3. **验收实测**:PUBLIC_POOL=2 / MY_LEAD=3 / MY_FOLLOW=2 / MANAGE=8(含存量 3 条未分发)。
4. **发现项**(票据第 4 项,写接口真实跑了一遍):
- **转商机被阻塞(65008)**:`OpportunityCreationPort` 无实现 bean,商机模块不在本 effort 范围(map Out of scope)——线索流转的「转商机」环节前端联调会报错,需产品决策(补商机最小实现 or 联调时降级该按钮)。已归票据 07 盘点清单。
- **「过期失效」状态无 API 可铺**:失效由定时任务扫描 expire_deadline 触发,种子不含该状态;如需展示可临时 SQL 改 status=6。
- **「已转商机」状态暂缺**:同上阻塞,状态标签在列表中不可见,待转商机制定后补铺。
- 铺写链路本身无其他阻塞:建池/建线索/领取/反馈/关注/四视图分页全部一次通过;必填校验(省/市/渠道/品牌/产品/咨询内容)与 DB NOT NULL 对齐,无误拦(必填口径终审归票据 07)。
已知边界:全部种子挂在存量单用户(罗伟强)名下,「不同销售」维度待钉钉权限开通后(票据 03)重同步真实用户再补铺;同步后也建议重跑本脚本核对幂等。

19
.scratch/frontend-integration/issues/06-static-demo.md

@ -0,0 +1,19 @@
# 核心流程静态 demo
Label: wayfinder:prototype
Type: prototype
Status: claimed
Blocked by: 05
## Question
用 /prototype 技能做一个零构建的静态 HTML+JS 单页 demo(放 `demo/``deploy/demo/`,纯 fetch 直调后端 API,无 Node/构建链),覆盖核心流程的**点击级**验证,作为前端联调的「正确传参照」:
- 登录(token 存取)
- 省市区三级级联下拉(`/api/rule/region/*`)+ 字典下拉(渠道/品牌/需求产品/需求场景)
- 新增线索表单(含必填/非必填实测、字典与区划提交值)+ 连续新增
- 四列表页(线索公海/我的线索/我的关注/线索管理)分页 + 筛选
- 线索流转:领取/跟进反馈/释放/转商机/分配
- 公海池配置(新增/编辑)
要求:每个接口的请求/响应原文直接渲染在页面上;页面直接抛出的错误(4xx/5xx/字段缺失)一目了然。验完部署至测试服务器 `nginx/html/demo/`

21
.scratch/frontend-integration/issues/07-api-audit-against-prototype.md

@ -0,0 +1,21 @@
# 核心接口对照原型盘点与修复
Label: wayfinder:task
Type: task
Status: open
Blocked by: 06
## Question
以 demo + Bruno 集合为载体,逐页对照蓝湖原型(`.scratch/lanhu-text/`)盘点并修复核心流程接口问题,产出问题清单并清零阻塞项。
盘点范围(按核心流程):认证登录 → 行政区划/字典读取 → 新增线索(必填校验、提交、回显)→ 四列表(分页/筛选/列字段)→ 线索流转(领取/反馈/释放/转商机/分配)→ 公海池 CRUD。
判定基准(见 map.md Notes):
- 原型表单标注为唯一权威;冲突以原型为准改后端
- 含糊/未标注 → 宽松优先,仅 DB NOT NULL 无默认值字段做最小校验
- 详情接口必须覆盖新建表单全部字段(编码 + 名称快照、区划 ID + 名称)
重点排查(用户联调反馈的四类):必填误校验导致接口报错、传参与返回不合理、字段未回显、数据未初始化(后三类在前置票据中已部分解决,此处复核)。
产出:问题清单(问题、原型依据、修复方式)+ 修复代码。按模块/页面拆分的修复子票从事后从 map「Not yet specified」毕业立票。

45
.scratch/frontend-integration/map.md

@ -0,0 +1,45 @@
# Wayfinder Map: 前端联调可用性保障(frontend-integration)
Label: wayfinder:map
## Destination
前端按接口文档 + 蓝湖原型能走通核心流程(认证登录 → 公共依赖[省市区/字典] → 线索主流程[新增/四列表/流转/公海池]),期间发现的阻塞项全部清零,并以静态 demo 作为正确性参照交付。
## Notes
- **本 effort 携带执行**(覆盖 Wayfinder 默认的 plan-don't-do):数据初始化、bug 修复、demo、测试数据铺设均作为地图内票据推进。
- **必填/非必填判定基准**(已与用户对齐):
- 蓝湖原型表单标注为唯一权威来源;接口文档与原型冲突时,以原型为准改后端。
- 原型含糊/未标注时**宽松优先**:服务端不做强制校验,仅对「数据库 NOT NULL 且无默认值」字段做最小校验;宁可放过,不可误拦。
- 回显规则:详情接口返回字段必须覆盖新建表单全部字段(含字典编码 + 名称快照、区划 ID + 名称)。
- **钉钉同步方案**(已与用户对齐):application.yml 的钉钉配置可用;引入 `com.github.tingyugetc520:dt-java:0.1.2`;只拉部门树 + 用户;**部门负责人是钉钉字段随部门存下,不是系统角色**;另写管理员角色配置接口。
- **字典种子**(已与用户对齐,票据 02 落地时以原型实际清单校正):lead_source 选项替换为原型渠道清单(38 项);新增 brand(9 项,非最初记录的仅 itc)/ product(16 项)/ scene(18 项)三个内置分组;customer_status / industry 保留不动;联调库直接重建或手工清理「只加不删」seeder 的残留。
- **行政区划**(已与用户对齐):全量国标三级(约 3200 行),seeder 幂等导入(与 DictDataInitializer 同款模式),非 SQL 脚本。
- **测试数据**(已与用户对齐):用户/组织走钉钉同步端点拉全量真实数据;角色用管理员角色配置接口手工配;公海池 ×2 + 各状态线索种子走后端 API 铺设。
- **环境流转**(已与用户对齐):本地 `:8081`(连 8.129.84.155 开发库)验证 → 打包 → `deploy/restart.sh` 更新测试服务器(nginx 80)交付前端;demo 部署至 `nginx/html/demo/`
- 相关技能:demo 票据用 /prototype;修复票据酌情用 /tdd。
- 原型文本位置:`.scratch/lanhu-text/`(新增线索表单见 a2-1-2-2____.txt);Bruno 集合:`.scratch/bruno-coldstart/`。
## Decisions so far
<!-- 一行一条已关闭票据:足以判断相关性,细节看链接 -->
- [dt-java SDK 部门与用户拉取 API 调研](issues/04-dtjava-sdk-research.md) — dt-java 0.1.2 存在于中央仓库(旧版 oapi 封装);**实测校正**:初始化只需 appKey/appSecret(无需 corp-id/agent-id);部门树 `list(null,true)` 可用;发布版 jar 的 `DtUser` **无** isLeaderInDeparts 字段(tmp 源码是更新版),用户链路改 HTTP 直调 `/topapi/user/listbypage` 自行解析负责人;传递依赖旧需 exclusions(xstream)。
- [行政区划全量种子导入](issues/01-region-seed.md) — china-division 2.7.0 国标三级 3432 行(34 省含港澳台/342 市/3056 区县镇街)内嵌 resources;`RegionDataInitializer`(@Order(12) CommandLineRunner)三层 batchUpsert + 回查拿 id,命中 uk_region_code 幂等;开发库两次启动实测写入与幂等通过。
- [字典种子对齐原型](issues/02-dict-seed-align.md) — SEED_GROUPS 对齐蓝湖 A2-1-2-2:lead_source 改名「渠道」并替换为 38 项原型渠道;新增 brand(9)/product(16)/scene(18) 共 6 组 92 项;crm-lead 分组编码常量由点号改齐 `lead_source/brand/product/scene`;「只加不删」,旧渠道项待联调库重建或手工停用。
- [钉钉组织同步端点与管理员角色配置接口](issues/03-dingtalk-sync-endpoint.md) — `POST /api/system/sync/dingtalk`(超管):部门走 dt-java SDK、用户因 0.1.2 缺 isLeaderInDeparts 改 HTTP 直调 listbypage;幂等键 `sys_dept.dt_dept_id`(新增唯一索引)与 `account`=钉钉 userid;负责人是部门字段反写 leader_user_id 不映射角色;`POST /api/system/users/admin-role`(超管)增量授予/回收角色,缺省 ROLE_ADMIN;错误码 61017。
- [公海池与线索种子数据铺设](issues/05-testdata-seeding.md) — `seed-testdata.py`(幂等纯 API 铺设):公海池×2(不同部门/省份/领取规则)+ 线索种子×5(待领取/已领取/跟进中)+ 关注×2,四视图均有数据;**发现项**:转商机 65008(OpportunityCreationPort 无实现,商机模块在目的地外,归票据 07 决策)、过期失效/已转商机状态无 API 可铺;全部种子挂存量单用户,待钉钉同步后补铺。
## Not yet specified
- **盘点产出的具体 bug 修复票据**:核心接口对照原型盘点(票据「核心接口对照原型盘点与修复」)会产出问题清单,届时按模块/页面逐一立票修复(含必填误校验、传参返回不合理、字段未回显)。
- **端到端验收走查**:数据、demo、修复齐备后,全流程点击验收 + 前端确认的最终票据,待前置完成后再立。
- **联调问题回流机制**:前端联调期间新反馈的 bug 如何归类进本地图(还是单开 issue),待第一轮联调反馈到来时再定。
## Out of scope
- **系统管理页面(用户/组织/角色管理页)本身的完整联调**——本 effort 只做钉钉同步与管理员角色配置接口,管理页 UI 联调不在目的地内。
- **数据字典管理页联调**——只保证读路径可用 + 内置字典齐全,管理页本身的联调 bug 非阻塞。
- **文件/附件模块联调**——线索表单如无附件场景则整体不进核心。
- **客户/商机/项目管理等后续模块**——目的地之外,另起 effort。

266
.scratch/frontend-integration/seed-testdata.py

@ -0,0 +1,266 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
票据 05公海池与线索种子数据铺设脚本幂等可重跑
覆盖目标frontend-integration/issues/05-testdata-seeding.md
1. 公海池 ×2不同部门不同省份不同领取规则各配负责人
2. 线索种子待领取 / 已领取 / 跟进中 / 已转商机尽力商机模块未实现时记录缺口
3. 我的关注有数据follow 一条线索
4. 验收四视图分页均有数据打印统计
用法
python seed-testdata.py # 默认 http://localhost:8081
SEED_BASE=http://x.x.x.x python seed-testdata.py
python seed-testdata.py --user 739564171091247104
前置verify profile 运行中/api/auth/debug/token 可用目标用户具备 ROLE_ADMIN
幂等策略部门/公海池按名称匹配线索按 leadName 精确匹配流转按当前状态守卫
"""
import json
import os
import sys
import urllib.parse
import urllib.request
BASE = os.environ.get("SEED_BASE", "http://localhost:8081")
USER_ID = os.environ.get("SEED_USER_ID", "739564171091247104")
STATUS_NAMES = {1: "未分发", 2: "待领取", 3: "已领取", 4: "跟进中",
5: "已转商机", 6: "过期失效", 7: "线索作废"}
DEPT_NAME = "华南销售部(seed)"
POOL_A = {
"poolName": "华南公海池(seed)", "claimRule": 1,
"recycleDays": 7, "expireDays": 180, "dailyClaimLimit": 10, "holdLimit": 200,
"provinceCode": "440000", "cityCode": "440100",
}
POOL_B = {
"poolName": "华北公海池(seed)", "claimRule": 2,
"recycleDays": 10, "expireDays": 90, "dailyClaimLimit": 5, "holdLimit": 100,
"provinceCode": "110000", "cityCode": None,
}
# 线索种子:target 流转目标(claim=已领取 follow=跟进中 convert=已转商机),follow_me=当前用户关注
LEADS = [
{"leadName": "网络奇迹有限公司", "phone": "15611229615", "pool": "A", "target": None,
"provinceCode": "440000", "cityCode": "440100", "channelCode": "exhibition",
"brandCode": "itc", "productCode": "led_display", "sceneCode": "meeting_room",
"consultContent": "展会现场咨询LED显示屏,意向会议室改造项目", "follow_me": True},
{"leadName": "广州智联科技有限公司", "phone": "13800138001", "pool": "A", "target": "claim",
"provinceCode": "440000", "cityCode": "440100", "channelCode": "jd",
"brandCode": "shichang", "productCode": "conference_system", "sceneCode": "auditorium",
"consultContent": "京东渠道咨询会议系统", "follow_me": False},
{"leadName": "深圳华彩光电有限公司", "phone": "13800138002", "pool": "A", "target": "follow",
"provinceCode": "440000", "cityCode": "440300", "channelCode": "zhihu",
"brandCode": "itc", "productCode": "video_conference", "sceneCode": "command_center",
"consultContent": "知乎渠道咨询视频会议+指挥中心场景", "follow_me": False},
{"leadName": "东莞声视电子有限公司", "phone": "13800138003", "pool": "A", "target": "convert",
"provinceCode": "440000", "cityCode": "441900", "channelCode": "digital_av",
"brandCode": "three_a", "productCode": "sound_system", "sceneCode": "sports_venue",
"consultContent": "数字音视渠道咨询扩声系统(文体场馆)", "follow_me": False},
{"leadName": "北京京彩传媒有限公司", "phone": "13800138004", "pool": "B", "target": None,
"provinceCode": "110000", "cityCode": None, "channelCode": "netease_news",
"brandCode": "itc", "productCode": "record_broadcast", "sceneCode": "theater",
"consultContent": "网易新闻渠道咨询录播系统(剧院)", "follow_me": True},
]
FINDINGS = [] # 铺设过程发现的阻塞/异常(票据第 4 项)
def http(method, path, params=None, form=None, token=None):
url = BASE + path
if params:
url += "?" + urllib.parse.urlencode(params, doseq=True)
data = None
if form is not None:
form = {k: v for k, v in form.items() if v is not None}
data = urllib.parse.urlencode(form, doseq=True).encode("utf-8")
req = urllib.request.Request(url, data=data, method=method)
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
body = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = json.loads(e.read().decode("utf-8") or "{}")
if body.get("success") is not True:
raise RuntimeError(f"{method} {path} -> code={body.get('code')} msg={body.get('message')}")
return body.get("data")
def main():
user_id = USER_ID
for i, a in enumerate(sys.argv[1:]):
if a == "--user":
user_id = sys.argv[i + 2]
if a == "--base":
global BASE
BASE = sys.argv[i + 2]
print(f"== 票据05 测试数据铺设 @ {BASE} (操作人 {user_id}) ==")
# ---- 0. 调试 token(verify profile 后门,ADR-0014)----
token = http("GET", "/api/auth/debug/token", {"userId": user_id})
print("[ok] 获取调试 token")
# ---- 1. 行政区划 ID 定位(sys_region 数据库主键,供公海池区域关联)----
provinces = http("GET", "/api/rule/region/level", {"level": 1}, token=token)
prov_by_code = {p["code"]: p for p in provinces}
region_ids = {} # poolKey -> {"province": id, "city": id|None}
first_city_by_prov = {} # 省 code -> 首个下级市 code(直辖市等无明确市的线索回填用)
for key, spec in (("A", POOL_A), ("B", POOL_B)):
prov = prov_by_code.get(spec["provinceCode"])
if not prov:
raise RuntimeError(f"省份 {spec['provinceCode']} 未入库(票据01 区划种子缺失)")
city_id = None
city_code = spec["cityCode"]
children = http("GET", "/api/rule/region/list", {"parentId": prov["id"]}, token=token)
if children:
first_city_by_prov[spec["provinceCode"]] = children[0]["code"]
if city_code is None:
# 直辖市:取第一个下级作为市
if children:
city_code = children[0]["code"]
city_id = children[0]["id"]
spec["cityCode"] = city_code
else:
hit = next((c for c in children if c["code"] == city_code), None)
if not hit:
raise RuntimeError(f"{city_code} 不在省 {spec['provinceCode']}")
city_id = hit["id"]
region_ids[key] = {"province": prov["id"], "city": city_id}
print(f"[ok] 池{key} 区域定位:省 {prov['name']}({prov['id']})"
+ (f"{city_code}({city_id})" if city_id else " 仅省级"))
# ---- 2. 部门:保证 ≥2 个(一部门一池约束)----
def flatten(nodes, out):
for n in nodes or []:
out.append(n)
flatten(n.get("children"), out)
return out
depts = flatten(http("GET", "/api/system/depts/tree", token=token), [])
seed_dept = next((d for d in depts if d.get("deptName") == DEPT_NAME), None)
if seed_dept is None:
http("POST", "/api/system/depts/save", form={"deptName": DEPT_NAME, "sort": 90}, token=token)
depts = flatten(http("GET", "/api/system/depts/tree", token=token), [])
seed_dept = next(d for d in depts if d.get("deptName") == DEPT_NAME)
print(f"[ok] 创建部门 {DEPT_NAME}({seed_dept['id']})")
else:
print(f"[skip] 部门 {DEPT_NAME} 已存在({seed_dept['id']})")
if len(depts) < 2:
raise RuntimeError("部门数不足 2,无法建两个池")
dept_a = seed_dept["id"]
dept_b = next(d["id"] for d in depts if d["id"] != dept_a)
print(f"[ok] 池A部门={dept_a}({seed_dept.get('deptName')}) 池B部门={dept_b}")
# ---- 3. 公海池 ×2(按名称幂等)----
pool_page = http("POST", "/api/rule/pool/page", form={"current": 1, "size": 100}, token=token)
pools_by_name = {p["poolName"]: p for p in pool_page.get("content", [])}
pool_ids = {}
for key, spec, dept_id in (("A", POOL_A, dept_a), ("B", POOL_B, dept_b)):
exist = pools_by_name.get(spec["poolName"])
if exist:
pool_ids[key] = exist["id"]
print(f"[skip] 公海池 {spec['poolName']} 已存在({exist['id']})")
continue
form = {
"poolName": spec["poolName"], "deptId": dept_id,
"claimRule": spec["claimRule"], "recycleDays": spec["recycleDays"],
"expireDays": spec["expireDays"], "dailyClaimLimit": spec["dailyClaimLimit"],
"holdLimit": spec["holdLimit"], "ownerUserId": user_id,
"provinceRegionIds": [region_ids[key]["province"]],
}
if region_ids[key]["city"]:
form["cityRegionIds"] = [region_ids[key]["city"]]
http("POST", "/api/rule/pool/saveOrUpdate", form=form, token=token)
pool_page = http("POST", "/api/rule/pool/page", form={"current": 1, "size": 100}, token=token)
pools_by_name = {p["poolName"]: p for p in pool_page.get("content", [])}
pool_ids[key] = pools_by_name[spec["poolName"]]["id"]
print(f"[ok] 创建公海池 {spec['poolName']}({pool_ids[key]}) "
f"规则={spec['claimRule']} 部门={dept_id}")
# ---- 4. 线索种子(按 leadName 幂等 + 状态守卫流转)----
lead_ids = {}
for spec in LEADS:
page = http("POST", "/api/lead/page",
form={"current": 1, "size": 20, "viewType": "MANAGE",
"keyword": spec["leadName"]}, token=token)
hit = next((l for l in page.get("content", []) if l.get("leadName") == spec["leadName"]), None)
if hit:
lead_ids[spec["leadName"]] = hit["id"]
print(f"[skip] 线索 {spec['leadName']} 已存在({hit['id']} 状态={STATUS_NAMES.get(hit.get('status'))})")
continue
form = {
"leadName": spec["leadName"], "phone": spec["phone"],
"provinceCode": spec["provinceCode"],
"cityCode": spec["cityCode"] or first_city_by_prov.get(spec["provinceCode"]),
"channelCode": spec["channelCode"], "brandCode": spec["brandCode"],
"productCode": spec["productCode"], "sceneCode": spec["sceneCode"],
"consultContent": spec["consultContent"],
"poolId": pool_ids[spec["pool"]],
}
claim_on_create = spec["target"] in ("claim", "follow", "convert")
lid = http("POST", "/api/lead/create",
params={"claimOnCreate": str(claim_on_create).lower()}, form=form, token=token)
lead_ids[spec["leadName"]] = lid
print(f"[ok] 创建线索 {spec['leadName']}({lid}) claimOnCreate={claim_on_create}")
# ---- 5. 流转(状态守卫:已到位则跳过)----
def status_of(lid):
return http("GET", "/api/lead/detail", {"id": lid}, token=token).get("status")
for spec in LEADS:
lid = lead_ids[spec["leadName"]]
target = spec["target"]
if target in ("follow", "convert"):
if status_of(lid) == 3: # 已领取 → 提交反馈 → 跟进中
http("POST", "/api/lead/feedback-submit", form={
"leadId": lid, "feedbackStatus": 1,
"content": "种子铺设自动反馈:客户意向明确", "productCode": spec["productCode"],
}, token=token)
print(f"[ok] {spec['leadName']} 提交反馈 → 跟进中")
if target == "convert":
if status_of(lid) in (3, 4):
try:
http("POST", "/api/lead/convert", params={"id": lid}, form={
"opportunityName": spec["leadName"] + "商机",
"industryCode": "it", "intendedCustomer": spec["leadName"],
"regionCode": spec["cityCode"] or spec["provinceCode"],
}, token=token)
print(f"[ok] {spec['leadName']} 转商机 → 已转商机")
except RuntimeError as e:
FINDINGS.append(f"转商机被阻塞:{spec['leadName']} -> {e}")
print(f"[!!] {spec['leadName']} 转商机失败(记录为发现项):{e}")
# ---- 6. 关注(我的关注视图有数据)----
for spec in LEADS:
if spec.get("follow_me"):
lid = lead_ids[spec["leadName"]]
http("POST", "/api/lead/follow", params={"id": lid}, form={}, token=token)
print(f"[ok] 关注线索 {spec['leadName']}(幂等)")
# ---- 7. 验收:四视图 + 统计 ----
print("\n== 验收 ==")
for view in ("PUBLIC_POOL", "MY_LEAD", "MY_FOLLOW", "MANAGE"):
page = http("POST", "/api/lead/page",
form={"current": 1, "size": 10, "viewType": view}, token=token)
total = page.get("total")
names = [l.get("leadName") for l in page.get("content", [])[:5]]
print(f"[view] {view}: total={total} 样例={names}")
stats = http("POST", "/api/lead/stats", form={"viewType": "MANAGE"}, token=token)
print(f"[stats] MANAGE 状态分布: {stats}")
pool_page = http("POST", "/api/rule/pool/page", form={"current": 1, "size": 10}, token=token)
print(f"[pools] {[(p['poolName'], p.get('deptName'), p.get('ownerUserName')) for p in pool_page.get('content', [])]}")
if FINDINGS:
print("\n== 发现项(阻塞/异常,需跟进) ==")
for f in FINDINGS:
print(" - " + f)
print("\n== 铺设完成 ==")
if __name__ == "__main__":
main()

14
crm-auth/pom.xml

@ -34,6 +34,20 @@
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- 钉钉开放平台 SDK(组织同步:部门树 + 用户),旧版 oapi 封装 -->
<dependency>
<groupId>com.github.tingyugetc520</groupId>
<artifactId>dt-java</artifactId>
<version>0.1.2</version>
<exclusions>
<!-- xstream 1.4.15 存在 CVE,本工程不用 XML 序列化,直接排除 -->
<exclusion>
<groupId>com.thoughtworks.xstream</groupId>
<artifactId>xstream</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 本地调试运行用 -->
<dependency>
<groupId>com.mysql</groupId>

36
crm-auth/src/main/java/com/crm/auth/config/DtClientConfig.java

@ -0,0 +1,36 @@
package com.crm.auth.config;
import com.github.tingyugetc520.ali.dingtalk.api.DtService;
import com.github.tingyugetc520.ali.dingtalk.api.impl.DtServiceImpl;
import com.github.tingyugetc520.ali.dingtalk.config.impl.DtDefaultConfigImpl;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 钉钉开放平台 SDKdt-java客户端配置组织同步部门树 + 用户使用
* <p>复用 {@code crm.auth.dingtalk.client-id/client-secret}即钉钉 AppKey/AppSecret
* SDK 内部管理企业级 access token 的获取与自动刷新部门/用户链路无需 corpId/agentId</p>
*/
@Configuration
@RequiredArgsConstructor
public class DtClientConfig {
private final AuthProperties authProperties;
@Bean
public DtService dtService() {
AuthProperties.DingTalk dingTalk = authProperties.getDingtalk();
if (StrUtil.isBlank(dingTalk.getClientId()) || StrUtil.isBlank(dingTalk.getClientSecret())) {
throw new IllegalStateException(
"钉钉组织同步需要 client-id / client-secret,请在 crm.auth.dingtalk 中配置或通过环境变量注入");
}
DtDefaultConfigImpl config = new DtDefaultConfigImpl();
config.setAppKey(dingTalk.getClientId());
config.setAppSecret(dingTalk.getClientSecret());
DtService dtService = new DtServiceImpl();
dtService.setDtConfigStorage(config);
return dtService;
}
}

6
crm-auth/src/main/java/com/crm/auth/constant/AuthConstants.java

@ -84,4 +84,10 @@ public interface AuthConstants {
/** 不支持的端类型 */
int CODE_UNSUPPORTED_CLIENT_TYPE = 61016;
/** 钉钉组织同步失败(凭证/接口调用失败/同步进行中) */
int CODE_DINGTALK_SYNC_FAIL = 61017;
/** 内置管理员角色编码(角色管理配置接口的默认目标角色) */
String ROLE_CODE_ADMIN = "ROLE_ADMIN";
}

29
crm-auth/src/main/java/com/crm/auth/controller/SystemController.java

@ -1,13 +1,17 @@
package com.crm.auth.controller;
import cn.hutool.core.util.StrUtil;
import com.crm.auth.constant.AuthConstants;
import com.crm.auth.domain.dto.AssignDeptDTO;
import com.crm.auth.domain.dto.ConfigureAdminRoleDTO;
import com.crm.auth.domain.dto.DeptDTO;
import com.crm.auth.domain.dto.DtSyncResultDTO;
import com.crm.auth.domain.dto.ResourceNodeDTO;
import com.crm.auth.domain.dto.UserListDTO;
import com.crm.auth.domain.dto.UserStatsDTO;
import com.crm.auth.domain.param.UserPageParam;
import com.crm.auth.service.IAuthUserService;
import com.crm.auth.service.IDtOrgSyncService;
import com.crm.auth.service.ISysDeptService;
import com.crm.auth.service.IUserQueryService;
import com.crm.auth.security.PermissionResolver;
@ -31,6 +35,7 @@ public class SystemController {
private final IAuthUserService authUserService;
private final IUserQueryService userQueryService;
private final PermissionResolver permissionResolver;
private final IDtOrgSyncService dtOrgSyncService;
// ========== 菜单 ==========
@ -98,6 +103,30 @@ public class SystemController {
return Result.success(userQueryService.stats(deptId));
}
// ========== 钉钉组织同步(票据 03) ==========
/** 全量同步钉钉部门树与用户(幂等,可重跑);同步阻塞,组织规模大时耗时较长 */
@PostMapping("/sync/dingtalk")
@PreAuthorize("hasRole('ADMIN')")
public Result<DtSyncResultDTO> syncDingtalk() {
return Result.success(dtOrgSyncService.syncOrg());
}
/** 管理员角色配置(票据 03):给指定用户增量授予/回收管理员类角色,不影响其他角色 */
@PostMapping("/users/admin-role")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> configureAdminRole(ConfigureAdminRoleDTO dto) {
List<String> roleCodes = StrUtil.isBlank(dto.getRoleCodes())
? List.of(AuthConstants.ROLE_CODE_ADMIN)
: Arrays.stream(dto.getRoleCodes().split(","))
.map(String::trim)
.filter(StrUtil::isNotBlank)
.toList();
authUserService.configureAdminRoles(dto.getUserId(), roleCodes,
dto.getGrant() == null || dto.getGrant());
return Result.success();
}
/** 逗号分隔 ID 串 → Long 列表(空串/null → 空列表) */
private static List<Long> splitIds(String ids) {
return StrUtil.isBlank(ids)

29
crm-auth/src/main/java/com/crm/auth/domain/dto/ConfigureAdminRoleDTO.java

@ -0,0 +1,29 @@
package com.crm.auth.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 管理员角色配置入参票据 03ADR-0017 POJO无主键语义故不继承 BaseDTO
* <p>给指定用户增量授予/回收管理员类角色只动目标角色行不影响其他角色
* 解决钉钉同步进来的用户如何拿到角色roleCodes 为逗号分隔的角色编码串
* Controller 拆分缺省为 ROLE_ADMIN</p>
*/
@Data
public class ConfigureAdminRoleDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "用户 ID")
private Long userId;
@Schema(description = "角色编码列表,逗号分隔,缺省 ROLE_ADMIN")
private String roleCodes;
@Schema(description = "true=授予(默认),false=回收")
private Boolean grant;
}

31
crm-auth/src/main/java/com/crm/auth/domain/dto/DtSyncResultDTO.java

@ -0,0 +1,31 @@
package com.crm.auth.domain.dto;
import lombok.Data;
/**
* 钉钉组织同步结果统计票据 03同步端点返回值
*/
@Data
public class DtSyncResultDTO {
/** 钉钉侧部门总数 */
private int deptTotal;
/** 本次新增的部门数 */
private int deptInserted;
/** 本次更新的部门数(改名/移动/排序变化) */
private int deptUpdated;
/** 钉钉侧用户总数(去重后) */
private int userTotal;
/** 本次新增的用户数 */
private int userInserted;
/** 本次更新的用户数 */
private int userUpdated;
/** 反写部门负责人(leaderUserId)的部门数 */
private int leaderAssigned;
}

8
crm-auth/src/main/java/com/crm/auth/domain/entity/SysDept.java

@ -13,7 +13,9 @@ import java.util.List;
@EqualsAndHashCode(callSuper = true)
@TableName("sys_dept")
@Entity
@Table(name = "sys_dept")
@Table(name = "sys_dept", indexes = {
@Index(name = "uk_dt_dept_id", columnList = "dt_dept_id", unique = true)
})
public class SysDept extends BaseEntity {
@Comment("上级部门ID,根节点为0")
@ -36,6 +38,10 @@ public class SysDept extends BaseEntity {
@Column(columnDefinition = "bigint")
private Long leaderUserId;
@Comment("钉钉外部部门ID(组织同步幂等键,手动建的部门为 NULL)")
@Column(name = "dt_dept_id", columnDefinition = "bigint comment '钉钉外部部门ID'")
private Long dtDeptId;
/**
* 子部门列表非数据库字段仅供 TreeUtils 构建树时填充
*/

12
crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java

@ -30,4 +30,16 @@ public interface IAuthUserService extends IBaseService<AuthUser> {
* @param roleIds 角色 ID 列表
*/
void assignRoles(Long userId, List<Long> roleIds);
/**
* 管理员角色配置票据 03给指定用户增量授予/回收管理员类角色
* <p> {@link #assignRoles} 的全量替换不同本方法只动目标角色行
* 不影响用户已有的其他角色部门负责人/销售角色仍由角色管理体系承载
* 专门解决钉钉同步进来的用户如何拿到角色</p>
*
* @param userId 目标用户 ID
* @param roleCodes 角色编码列表 ROLE_ADMIN必须全部存在
* @param grant true=授予幂等false=回收无则忽略
*/
void configureAdminRoles(Long userId, List<String> roleCodes, boolean grant);
}

19
crm-auth/src/main/java/com/crm/auth/service/IDtOrgSyncService.java

@ -0,0 +1,19 @@
package com.crm.auth.service;
import com.crm.auth.domain.dto.DtSyncResultDTO;
/**
* 钉钉组织同步服务票据 03
* <p>全量拉取钉钉部门树与用户幂等 upsert sys_dept / crm_auth_user
* 并把钉钉部门负责人字段反写到 sys_dept.leader_user_id</p>
*/
public interface IDtOrgSyncService {
/**
* 执行一次全量组织同步同步阻塞仅超管通过端点触发
* <p>幂等重跑不产生重复数据钉钉侧改名/调部门可在下一次同步中更新</p>
*
* @return 本次同步的统计结果
*/
DtSyncResultDTO syncOrg();
}

51
crm-auth/src/main/java/com/crm/auth/service/impl/AuthUserServiceImpl.java

@ -2,13 +2,16 @@ package com.crm.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.hutool.core.util.StrUtil;
import com.crm.auth.constant.AuthConstants;
import com.crm.auth.domain.entity.AuthUser;
import com.crm.auth.domain.entity.SysDept;
import com.crm.auth.domain.entity.SysRole;
import com.crm.auth.domain.entity.SysUserDept;
import com.crm.auth.domain.entity.SysUserRole;
import com.crm.auth.mapper.AuthUserMapper;
import com.crm.auth.mapper.SysDeptMapper;
import com.crm.auth.mapper.SysRoleMapper;
import com.crm.auth.mapper.SysUserDeptMapper;
import com.crm.auth.mapper.SysUserRoleMapper;
import com.crm.auth.service.IAuthUserService;
@ -21,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@ -31,6 +35,7 @@ public class AuthUserServiceImpl extends BaseServiceImpl<AuthUserMapper, AuthUse
private final SysUserDeptMapper sysUserDeptMapper;
private final SysDeptMapper sysDeptMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final SysRoleMapper sysRoleMapper;
@Override
public AuthUser getByMobile(String mobile) {
@ -105,4 +110,50 @@ public class AuthUserServiceImpl extends BaseServiceImpl<AuthUserMapper, AuthUse
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void configureAdminRoles(Long userId, List<String> roleCodes, boolean grant) {
// 参数校验先于查库,避免非法参数也打一次 DB
if (userId == null) {
throw new BusinessErrorException(AuthConstants.CODE_ROLE_INVALID, "用户ID不能为空");
}
List<String> codes = roleCodes == null
? List.of()
: roleCodes.stream().filter(StrUtil::isNotBlank).distinct().toList();
if (codes.isEmpty()) {
throw new BusinessErrorException(AuthConstants.CODE_ROLE_INVALID, "角色编码不能为空");
}
if (getById(userId) == null) {
throw new ResourceNotExistException("用户不存在: " + userId);
}
// 角色编码必须全部存在,避免静默丢弃拼错的编码
List<SysRole> roles = sysRoleMapper.selectList(
new LambdaQueryWrapper<SysRole>().in(SysRole::getRoleCode, codes));
Map<String, Long> idByCode = roles.stream()
.collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getId, (a, b) -> a));
List<String> missing = codes.stream().filter(c -> !idByCode.containsKey(c)).toList();
if (!missing.isEmpty()) {
throw new BusinessErrorException(AuthConstants.CODE_ROLE_INVALID, "角色不存在: " + missing);
}
// 只动目标角色行:授予幂等(已存在不重复插),回收无则忽略
for (Long roleId : idByCode.values()) {
Long exists = sysUserRoleMapper.selectCount(new LambdaQueryWrapper<SysUserRole>()
.eq(SysUserRole::getUserId, userId)
.eq(SysUserRole::getRoleId, roleId));
boolean bound = exists != null && exists > 0;
if (grant && !bound) {
SysUserRole ur = new SysUserRole();
ur.setUserId(userId);
ur.setRoleId(roleId);
sysUserRoleMapper.insert(ur);
} else if (!grant && bound) {
sysUserRoleMapper.delete(new LambdaQueryWrapper<SysUserRole>()
.eq(SysUserRole::getUserId, userId)
.eq(SysUserRole::getRoleId, roleId));
}
}
}
}

523
crm-auth/src/main/java/com/crm/auth/service/impl/DtOrgSyncServiceImpl.java

@ -0,0 +1,523 @@
package com.crm.auth.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.crm.auth.constant.AuthConstants;
import com.crm.auth.domain.dto.DtSyncResultDTO;
import com.crm.auth.domain.entity.AuthIdentity;
import com.crm.auth.domain.entity.AuthUser;
import com.crm.auth.domain.entity.SysDept;
import com.crm.auth.domain.entity.SysUserDept;
import com.crm.auth.domain.enums.IdentityTypeEnum;
import com.crm.auth.mapper.AuthIdentityMapper;
import com.crm.auth.mapper.AuthUserMapper;
import com.crm.auth.mapper.SysUserDeptMapper;
import com.crm.auth.service.IDtOrgSyncService;
import com.crm.auth.service.ISysDeptService;
import com.crm.base.domain.exception.BusinessErrorException;
import com.github.tingyugetc520.ali.dingtalk.api.DtService;
import com.github.tingyugetc520.ali.dingtalk.bean.department.DtDepart;
import com.github.tingyugetc520.ali.dingtalk.error.DtErrorException;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 钉钉组织同步实现票据 03
* <p>流程</p>
* <ol>
* <li>dt-java SDK {@code DtDepartmentService.list(null, true)} 一次性拉全量部门树
* dt_dept_id 幂等 upsert sys_deptBFS 保证父部门先落库祖级链复用 {@link ISysDeptService#saveOrUpdate}</li>
* <li>逐部门分页拉用户详情旧版 oapi {@code /topapi/user/listbypage}HTTP 直调 DingTalkAuthClient 同款手法
* account=钉钉 userid 幂等 upsert crm_auth_user首个出现的部门记为主部门其余为 sys_user_dept 兼职挂靠全量替换</li>
* <li>聚合响应中的 isLeaderInDepts钉钉的部门负责人是部门维度字段不是系统角色反写 sys_dept.leader_user_id
* 注意dt-java 0.1.2 发布版 DtUser 不含该字段因此用户链路不用 SDK bean 而直接解析 JSON</li>
* <li>unionId 写入 crm_auth_identitydingtalk + union_id 唯一保证后续扫码登录映射到同步下来的账号</li>
* </ol>
* <p>可测试性{@link #fetchDepartments()} {@link #fetchDeptUsersRaw(Long, int, int)} 为外部调用 seam
* 单元测试通过继承覆写注入桩数据</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DtOrgSyncServiceImpl implements IDtOrgSyncService {
/** 旧版 oapi 部门用户详情接口单页上限 */
private static final int PAGE_SIZE = 100;
/** 相邻两次钉钉请求之间的限流间隔(毫秒),避免触发频控 */
private static final long THROTTLE_MILLIS = 100L;
/** 部门用户详情接口(旧版 oapi,需企业级 access token) */
private static final String USER_LIST_URL = "https://oapi.dingtalk.com/topapi/user/listbypage";
/** 钉钉颁发 7200 秒,留 200 秒余量避免临界失效 */
private static final long CORP_TOKEN_TTL_SECONDS = 7000L;
private static final int TIMEOUT_MILLIS = 10_000;
/** 并发保护:同一时刻只允许一次同步 */
private final AtomicBoolean running = new AtomicBoolean(false);
private final DtService dtService;
private final ISysDeptService sysDeptService;
private final AuthUserMapper authUserMapper;
private final AuthIdentityMapper authIdentityMapper;
private final SysUserDeptMapper sysUserDeptMapper;
private final StringRedisTemplate stringRedisTemplate;
@Override
@Transactional(rollbackFor = Exception.class)
public DtSyncResultDTO syncOrg() {
if (!running.compareAndSet(false, true)) {
throw new BusinessErrorException(AuthConstants.CODE_DINGTALK_SYNC_FAIL, "已有组织同步任务进行中,请稍后再试");
}
try {
DtSyncResultDTO result = new DtSyncResultDTO();
// ===== 1. 部门 =====
List<DtDepart> dtDepts = orderByBreadthFirst(fetchDepartments());
result.setDeptTotal(dtDepts.size());
Map<Long, Long> dtDeptIdToLocalId = syncDepartments(dtDepts, result);
// ===== 2. 用户(主/兼职部门 + 身份绑定) =====
Map<String, DtUserRaw> dtUserByUserId = new LinkedHashMap<>();
Map<String, AuthUser> syncedUsers = syncUsers(dtDepts, dtDeptIdToLocalId, dtUserByUserId, result);
// ===== 3. 部门负责人反写 =====
assignDeptLeaders(dtUserByUserId, syncedUsers, dtDeptIdToLocalId, result);
log.info("钉钉组织同步完成:部门 {}(新增 {} 更新 {}),用户 {}(新增 {} 更新 {}),负责人反写 {}",
result.getDeptTotal(), result.getDeptInserted(), result.getDeptUpdated(),
result.getUserTotal(), result.getUserInserted(), result.getUserUpdated(),
result.getLeaderAssigned());
return result;
} catch (DtErrorException e) {
String detail = e.getError() != null
? e.getError().getErrorCode() + " " + e.getError().getErrorMsg()
: e.getMessage();
log.error("钉钉组织同步失败(部门链路):{}", detail, e);
throw new BusinessErrorException(AuthConstants.CODE_DINGTALK_SYNC_FAIL, "钉钉组织同步失败:" + detail);
} finally {
running.set(false);
}
}
// ==================== 部门 ====================
/**
* BFS 排序父部门未知不在拉取结果中的视为根逐层展开保证 upsert 时父部门已落库
*/
private List<DtDepart> orderByBreadthFirst(List<DtDepart> dtDepts) {
Map<Long, DtDepart> byId = new LinkedHashMap<>();
for (DtDepart d : dtDepts) {
byId.put(d.getId(), d);
}
Map<Long, List<DtDepart>> childrenByParent = new LinkedHashMap<>();
List<DtDepart> roots = new ArrayList<>();
for (DtDepart d : dtDepts) {
if (d.getParentId() == null || !byId.containsKey(d.getParentId())) {
roots.add(d);
} else {
childrenByParent.computeIfAbsent(d.getParentId(), k -> new ArrayList<>()).add(d);
}
}
List<DtDepart> ordered = new ArrayList<>(dtDepts.size());
Deque<DtDepart> queue = new ArrayDeque<>(roots);
while (!queue.isEmpty()) {
DtDepart d = queue.pollFirst();
ordered.add(d);
List<DtDepart> children = childrenByParent.get(d.getId());
if (children != null) {
queue.addAll(children);
}
}
// 兜底:理论上不会遗漏;若出现环则把剩余节点直接追加
if (ordered.size() < dtDepts.size()) {
for (DtDepart d : dtDepts) {
if (!ordered.contains(d)) {
ordered.add(d);
}
}
}
return ordered;
}
/**
* dt_dept_id 幂等 upsert 部门返回 钉钉部门ID 本地部门ID 映射
*/
private Map<Long, Long> syncDepartments(List<DtDepart> dtDepts, DtSyncResultDTO result) {
Map<Long, SysDept> existingByDtId = new HashMap<>();
for (SysDept dept : sysDeptService.list()) {
if (dept.getDtDeptId() != null) {
existingByDtId.put(dept.getDtDeptId(), dept);
}
}
Map<Long, Long> dtIdToLocalId = new HashMap<>();
for (DtDepart dtDept : dtDepts) {
Long localParentId = dtDept.getParentId() == null
? 0L
: dtIdToLocalId.getOrDefault(dtDept.getParentId(), 0L);
int sort = dtDept.getOrder() == null ? 0 : Math.toIntExact(Math.min(dtDept.getOrder(), Integer.MAX_VALUE));
SysDept dept = existingByDtId.get(dtDept.getId());
if (dept == null) {
dept = new SysDept();
dept.setDtDeptId(dtDept.getId());
dept.setParentId(localParentId);
dept.setDeptName(dtDept.getName());
dept.setSort(sort);
sysDeptService.saveOrUpdate(dept);
result.setDeptInserted(result.getDeptInserted() + 1);
} else {
boolean changed = !Objects.equals(dept.getParentId(), localParentId)
|| !Objects.equals(dept.getDeptName(), dtDept.getName())
|| !Objects.equals(dept.getSort(), sort);
if (changed) {
dept.setParentId(localParentId);
dept.setDeptName(dtDept.getName());
dept.setSort(sort);
sysDeptService.saveOrUpdate(dept);
result.setDeptUpdated(result.getDeptUpdated() + 1);
}
}
dtIdToLocalId.put(dtDept.getId(), dept.getId());
}
return dtIdToLocalId;
}
// ==================== 用户 ====================
/**
* 逐部门分页拉用户并幂等 upsertBFS 序保证首个出现的部门越靠近根越优先作为主部门
* dtUserByUserId 出参收集钉钉用户明细供负责人聚合使用
*/
private Map<String, AuthUser> syncUsers(List<DtDepart> dtDepts, Map<Long, Long> dtDeptIdToLocalId,
Map<String, DtUserRaw> dtUserByUserId, DtSyncResultDTO result) {
// userId -> 本地部门 id 列表(首个为主部门),LinkedHashMap 保持部门出现顺序
Map<String, List<Long>> userDeptIds = new LinkedHashMap<>();
for (DtDepart dtDept : dtDepts) {
Long localDeptId = dtDeptIdToLocalId.get(dtDept.getId());
for (DtUserRaw u : fetchDeptUsers(dtDept.getId())) {
if (StrUtil.isBlank(u.getUserid())) {
continue;
}
dtUserByUserId.putIfAbsent(u.getUserid(), u);
userDeptIds.computeIfAbsent(u.getUserid(), k -> new ArrayList<>());
if (localDeptId != null && !userDeptIds.get(u.getUserid()).contains(localDeptId)) {
userDeptIds.get(u.getUserid()).add(localDeptId);
}
}
throttle();
}
Map<String, AuthUser> synced = new LinkedHashMap<>();
for (Map.Entry<String, DtUserRaw> entry : dtUserByUserId.entrySet()) {
DtUserRaw dtUser = entry.getValue();
List<Long> localDeptIds = userDeptIds.getOrDefault(entry.getKey(), Collections.emptyList());
Long primaryDeptId = localDeptIds.isEmpty() ? null : localDeptIds.get(0);
AuthUser user = upsertUser(dtUser, primaryDeptId, result);
synced.put(entry.getKey(), user);
upsertIdentity(user, dtUser);
replacePartTimeDepts(user.getId(), primaryDeptId, localDeptIds);
}
result.setUserTotal(dtUserByUserId.size());
return synced;
}
/**
* account钉钉 userid幂等 upsert 单个用户
*/
private AuthUser upsertUser(DtUserRaw dtUser, Long primaryDeptId, DtSyncResultDTO result) {
AuthUser user = authUserMapper.selectOne(
new LambdaQueryWrapper<AuthUser>().eq(AuthUser::getAccount, dtUser.getUserid()));
boolean isNew = user == null;
if (isNew) {
user = new AuthUser();
user.setAccount(dtUser.getUserid());
user.setEnabled(Boolean.TRUE);
}
user.setUsername(dtUser.getName());
if (StrUtil.isNotBlank(dtUser.getMobile())) {
user.setMobile(dtUser.getMobile());
}
if (StrUtil.isNotBlank(dtUser.getEmail())) {
user.setEmail(dtUser.getEmail());
}
if (StrUtil.isNotBlank(dtUser.getAvatar())) {
user.setAvatar(dtUser.getAvatar());
}
user.setTitle(dtUser.getPosition());
if (primaryDeptId != null) {
user.setDeptId(primaryDeptId);
}
user.setEmploymentStatus(Boolean.FALSE.equals(dtUser.getActive()) ? "resigned" : "active");
if (isNew) {
authUserMapper.insert(user);
result.setUserInserted(result.getUserInserted() + 1);
} else {
authUserMapper.updateById(user);
result.setUserUpdated(result.getUserUpdated() + 1);
}
return user;
}
/**
* dingtalk + unionId 身份绑定 upsert保证扫码登录能映射到同步下来的账号
*/
private void upsertIdentity(AuthUser user, DtUserRaw dtUser) {
if (StrUtil.isBlank(dtUser.getUnionid())) {
return;
}
AuthIdentity identity = authIdentityMapper.selectOne(
new LambdaQueryWrapper<AuthIdentity>()
.eq(AuthIdentity::getIdentityType, IdentityTypeEnum.DINGTALK.getValue())
.eq(AuthIdentity::getUnionId, dtUser.getUnionid()));
if (identity == null) {
identity = new AuthIdentity();
identity.setUserId(user.getId());
identity.setIdentityType(IdentityTypeEnum.DINGTALK.getValue());
identity.setUnionId(dtUser.getUnionid());
identity.setMobile(dtUser.getMobile());
authIdentityMapper.insert(identity);
} else if (!identity.getUserId().equals(user.getId())) {
// 组织同步为准:把该钉钉身份改挂到同步下来的账号上
identity.setUserId(user.getId());
authIdentityMapper.updateById(identity);
}
}
/**
* 兼职部门全量替换删除该用户全部兼职挂靠再按钉钉最新部门列表重建不含主部门
*/
private void replacePartTimeDepts(Long userId, Long primaryDeptId, List<Long> localDeptIds) {
sysUserDeptMapper.delete(new LambdaQueryWrapper<SysUserDept>().eq(SysUserDept::getUserId, userId));
for (Long deptId : localDeptIds) {
if (deptId.equals(primaryDeptId)) {
continue;
}
SysUserDept link = new SysUserDept();
link.setUserId(userId);
link.setDeptId(deptId);
sysUserDeptMapper.insert(link);
}
}
// ==================== 部门负责人 ====================
/**
* 钉钉部门负责人存在用户详情的 isLeaderInDeptsMap&lt;部门ID, 是否负责人&gt;
* 是部门维度字段而非系统角色聚合后反写 sys_dept.leader_user_id
*/
private void assignDeptLeaders(Map<String, DtUserRaw> dtUserByUserId, Map<String, AuthUser> syncedUsers,
Map<Long, Long> dtDeptIdToLocalId, DtSyncResultDTO result) {
Map<Long, Long> leaderByLocalDeptId = new LinkedHashMap<>();
for (Map.Entry<String, DtUserRaw> entry : dtUserByUserId.entrySet()) {
DtUserRaw dtUser = entry.getValue();
if (dtUser.getIsLeaderInDepts() == null || dtUser.getIsLeaderInDepts().isEmpty()) {
continue;
}
AuthUser user = syncedUsers.get(entry.getKey());
if (user == null) {
continue;
}
for (Map.Entry<Long, Boolean> leaderEntry : dtUser.getIsLeaderInDepts().entrySet()) {
if (!Boolean.TRUE.equals(leaderEntry.getValue())) {
continue;
}
Long localDeptId = dtDeptIdToLocalId.get(leaderEntry.getKey());
if (localDeptId != null) {
leaderByLocalDeptId.put(localDeptId, user.getId());
}
}
}
for (Map.Entry<Long, Long> entry : leaderByLocalDeptId.entrySet()) {
SysDept dept = sysDeptService.getById(entry.getKey());
if (dept != null && !Objects.equals(dept.getLeaderUserId(), entry.getValue())) {
dept.setLeaderUserId(entry.getValue());
sysDeptService.updateById(dept);
result.setLeaderAssigned(result.getLeaderAssigned() + 1);
}
}
}
// ==================== 外部调用 seam ====================
/**
* 拉取全量部门树含根部门 dt-java SDKSDK 自管 tokenprotected单测可覆写注入桩数据
*/
protected List<DtDepart> fetchDepartments() throws DtErrorException {
List<DtDepart> depts = dtService.getDepartmentService().list(null, true);
return depts == null ? Collections.emptyList() : depts;
}
/**
* 分页拉取指定部门下的全部用户详情单页上限 100自行翻页
* protected单测可覆写注入桩数据
*/
protected List<DtUserRaw> fetchDeptUsers(Long dtDeptId) {
List<DtUserRaw> all = new ArrayList<>();
int offset = 0;
while (true) {
List<DtUserRaw> page = fetchDeptUsersRaw(dtDeptId, offset, PAGE_SIZE);
if (page.isEmpty()) {
break;
}
all.addAll(page);
if (page.size() < PAGE_SIZE) {
break;
}
offset += PAGE_SIZE;
throttle();
}
return all;
}
/**
* 单页拉取HTTP 直调旧版 oapidt-java 0.1.2 DtUser isLeaderInDepts 字段故不用 SDK bean
* protected单测可覆写注入桩数据
*/
protected List<DtUserRaw> fetchDeptUsersRaw(Long dtDeptId, int offset, int size) {
String corpToken = getCorpAccessToken();
String rawBody = requestUserListApi(corpToken, dtDeptId, offset, size);
JSONObject result = JSONUtil.parseObj(rawBody);
Integer errcode = result.getInt("errcode");
// 企业级 token 失效(40014 非法 / 42001 超时):清缓存重取重试一次
if (errcode != null && (errcode == 40014 || errcode == 42001)) {
log.warn("组织同步:企业级 token 已失效,清缓存重取重试,deptId={}", dtDeptId);
stringRedisTemplate.delete(AuthConstants.DINGTALK_CORP_TOKEN_KEY);
corpToken = getCorpAccessToken();
rawBody = requestUserListApi(corpToken, dtDeptId, offset, size);
result = JSONUtil.parseObj(rawBody);
errcode = result.getInt("errcode");
}
if (errcode == null || errcode != 0) {
log.error("组织同步拉取部门用户失败:deptId={}, body={}", dtDeptId, rawBody);
throw new BusinessErrorException(AuthConstants.CODE_DINGTALK_SYNC_FAIL,
"钉钉组织同步失败:拉取部门用户失败(errcode=" + errcode + ")");
}
JSONArray userlist = result.getJSONArray("userlist");
List<DtUserRaw> page = new ArrayList<>();
if (userlist != null) {
for (Object item : userlist) {
page.add(parseUserRaw((JSONObject) item));
}
}
return page;
}
/** 单条用户明细解析:isLeaderInDepts 的 key 是部门 ID 串,手动转 Long 避免泛型转换隐患 */
private DtUserRaw parseUserRaw(JSONObject item) {
DtUserRaw raw = JSONUtil.toBean(item, DtUserRaw.class);
JSONObject leaders = item.getJSONObject("isLeaderInDepts");
if (leaders != null && !leaders.isEmpty()) {
Map<Long, Boolean> leaderMap = new HashMap<>();
for (String deptIdKey : leaders.keySet()) {
leaderMap.put(Long.valueOf(deptIdKey), leaders.getBool(deptIdKey));
}
raw.setIsLeaderInDepts(leaderMap);
}
return raw;
}
/**
* 获取企业级 access token优先读 Redis 缓存 DingTalkAuthClient 共享 key未命中调钉钉接口回写
*/
private String getCorpAccessToken() {
String cached = stringRedisTemplate.opsForValue().get(AuthConstants.DINGTALK_CORP_TOKEN_KEY);
if (StrUtil.isNotBlank(cached)) {
return cached;
}
String appKey = Objects.requireNonNull(dtService.getDtConfigStorage().getAppKey(), "appKey 未配置");
String appSecret = Objects.requireNonNull(dtService.getDtConfigStorage().getAppSecret(), "appSecret 未配置");
JSONObject body = JSONUtil.createObj().set("appKey", appKey).set("appSecret", appSecret);
try (HttpResponse response = HttpRequest.post("https://api.dingtalk.com/v1.0/oauth2/accessToken")
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(TIMEOUT_MILLIS)
.execute()) {
if (!response.isOk()) {
throw new BusinessErrorException(AuthConstants.CODE_DINGTALK_SYNC_FAIL,
"钉钉企业凭证获取失败,status=" + response.getStatus());
}
String accessToken = JSONUtil.parseObj(response.body()).getStr("accessToken");
if (StrUtil.isBlank(accessToken)) {
throw new BusinessErrorException(AuthConstants.CODE_DINGTALK_SYNC_FAIL,
"钉钉企业凭证获取失败:" + response.body());
}
stringRedisTemplate.opsForValue().set(AuthConstants.DINGTALK_CORP_TOKEN_KEY, accessToken,
CORP_TOKEN_TTL_SECONDS, TimeUnit.SECONDS);
return accessToken;
} catch (BusinessErrorException e) {
throw e;
} catch (Exception e) {
log.error("调用钉钉 corp accessToken 接口异常", e);
throw new BusinessErrorException(AuthConstants.CODE_DINGTALK_SYNC_FAIL, "钉钉企业凭证获取失败,请稍后重试");
}
}
/**
* 发起部门用户详情 HTTP 请求返回响应体protected单测可覆写注入桩数据
*/
protected String requestUserListApi(String corpToken, Long dtDeptId, int offset, int size) {
JSONObject body = JSONUtil.createObj()
.set("department_id", dtDeptId)
.set("offset", offset)
.set("size", size);
try (HttpResponse response = HttpRequest.post(USER_LIST_URL + "?access_token=" + corpToken)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(TIMEOUT_MILLIS)
.execute()) {
if (!response.isOk()) {
throw new BusinessErrorException(AuthConstants.CODE_DINGTALK_SYNC_FAIL,
"钉钉部门用户查询失败,status=" + response.getStatus());
}
return response.body();
} catch (BusinessErrorException e) {
throw e;
} catch (Exception e) {
log.error("调用钉钉 listbypage 接口异常,deptId={}", dtDeptId, e);
throw new BusinessErrorException(AuthConstants.CODE_DINGTALK_SYNC_FAIL, "钉钉部门用户查询失败,请稍后重试");
}
}
private void throttle() {
try {
Thread.sleep(THROTTLE_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* listbypage 用户明细只取同步需要的字段字段名为钉钉原始 snake/camel 混排
*/
@Data
protected static class DtUserRaw {
private String userid;
private String unionid;
private String name;
private String mobile;
private String email;
private String avatar;
private String position;
private Boolean active;
private Map<Long, Boolean> isLeaderInDepts;
}
}

2
crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java

@ -518,7 +518,7 @@ class DataScopeIntegrationTest {
create table sys_dept (
id bigint primary key, creator_id varchar(50), create_time datetime,
updater_id varchar(50), update_time datetime, deleted tinyint not null default 0,
parent_id bigint, dept_name varchar(50), ancestors varchar(500), sort int, leader_user_id bigint)
parent_id bigint, dept_name varchar(50), ancestors varchar(500), sort int, leader_user_id bigint, dt_dept_id bigint)
""",
"""
create table test_owned_data (

78
crm-auth/src/test/java/com/crm/auth/service/impl/AuthUserServiceImplTest.java

@ -3,10 +3,14 @@ package com.crm.auth.service.impl;
import com.crm.auth.constant.AuthConstants;
import com.crm.auth.domain.entity.AuthUser;
import com.crm.auth.domain.entity.SysDept;
import com.crm.auth.domain.entity.SysRole;
import com.crm.auth.domain.entity.SysUserDept;
import com.crm.auth.domain.entity.SysUserRole;
import com.crm.auth.mapper.AuthUserMapper;
import com.crm.auth.mapper.SysDeptMapper;
import com.crm.auth.mapper.SysRoleMapper;
import com.crm.auth.mapper.SysUserDeptMapper;
import com.crm.auth.mapper.SysUserRoleMapper;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.domain.exception.ResourceNotExistException;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
@ -54,6 +58,10 @@ class AuthUserServiceImplTest {
private SysUserDeptMapper sysUserDeptMapper;
@Mock
private SysDeptMapper sysDeptMapper;
@Mock
private SysUserRoleMapper sysUserRoleMapper;
@Mock
private SysRoleMapper sysRoleMapper;
@InjectMocks
private AuthUserServiceImpl service;
@ -68,6 +76,8 @@ class AuthUserServiceImplTest {
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, AuthUser.class);
TableInfoHelper.initTableInfo(assistant, SysUserDept.class);
TableInfoHelper.initTableInfo(assistant, SysUserRole.class);
TableInfoHelper.initTableInfo(assistant, SysRole.class);
}
@BeforeEach
@ -188,4 +198,72 @@ class AuthUserServiceImplTest {
verify(sysUserDeptMapper).insert(any(SysUserDept.class));
}
// ==================== configureAdminRoles(票据 03) ====================
private void stubAdminRoleExists() {
SysRole admin = new SysRole();
admin.setId(77L);
admin.setRoleCode(AuthConstants.ROLE_CODE_ADMIN);
when(sysRoleMapper.selectList(any())).thenReturn(List.of(admin));
}
@Test
@DisplayName("管理员角色配置:用户不存在 -> 拒绝")
void adminRole_rejects_whenUserNotExist() {
when(authUserMapper.selectById(USER_ID)).thenReturn(null);
assertThatThrownBy(() -> service.configureAdminRoles(USER_ID, List.of(AuthConstants.ROLE_CODE_ADMIN), true))
.isInstanceOf(ResourceNotExistException.class);
verifyNoInteractions(sysUserRoleMapper);
}
@Test
@DisplayName("管理员角色配置:角色编码不存在 -> 拒绝并指出缺失编码")
void adminRole_rejects_whenRoleCodeMissing() {
stubUserExists();
when(sysRoleMapper.selectList(any())).thenReturn(List.of());
assertThatThrownBy(() -> service.configureAdminRoles(USER_ID, List.of("ROLE_NOT_EXIST"), true))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", AuthConstants.CODE_ROLE_INVALID)
.hasMessageContaining("ROLE_NOT_EXIST");
verifyNoInteractions(sysUserRoleMapper);
}
@Test
@DisplayName("管理员角色配置:授予幂等(已有不重复插),回收只删目标行")
void adminRole_grantIdempotent_andRevoke() {
stubUserExists();
stubAdminRoleExists();
// 授予:尚未绑定 -> 插入一行
when(sysUserRoleMapper.selectCount(any())).thenReturn(0L);
service.configureAdminRoles(USER_ID, List.of(AuthConstants.ROLE_CODE_ADMIN), true);
verify(sysUserRoleMapper).insert(any(SysUserRole.class));
verify(sysUserRoleMapper, never()).delete(any());
// 授予:已绑定 -> 不重复插
org.mockito.Mockito.reset(sysUserRoleMapper);
when(sysUserRoleMapper.selectCount(any())).thenReturn(1L);
service.configureAdminRoles(USER_ID, List.of(AuthConstants.ROLE_CODE_ADMIN), true);
verify(sysUserRoleMapper, never()).insert(any(SysUserRole.class));
// 回收:已绑定 -> 删除;未绑定 -> 不动
org.mockito.Mockito.reset(sysUserRoleMapper);
when(sysUserRoleMapper.selectCount(any())).thenReturn(1L);
service.configureAdminRoles(USER_ID, List.of(AuthConstants.ROLE_CODE_ADMIN), false);
verify(sysUserRoleMapper).delete(any());
verify(sysUserRoleMapper, never()).insert(any(SysUserRole.class));
}
@Test
@DisplayName("管理员角色配置:编码列表空白/用户ID为空 -> 参数非法")
void adminRole_rejects_blankParams() {
assertThatThrownBy(() -> service.configureAdminRoles(null, List.of(AuthConstants.ROLE_CODE_ADMIN), true))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", AuthConstants.CODE_ROLE_INVALID);
assertThatThrownBy(() -> service.configureAdminRoles(USER_ID, List.of(" "), true))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", AuthConstants.CODE_ROLE_INVALID);
verifyNoInteractions(sysUserRoleMapper);
}
}

273
crm-auth/src/test/java/com/crm/auth/service/impl/DtOrgSyncServiceImplTest.java

@ -0,0 +1,273 @@
package com.crm.auth.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.crm.auth.domain.dto.DtSyncResultDTO;
import com.crm.auth.domain.entity.AuthIdentity;
import com.crm.auth.domain.entity.AuthUser;
import com.crm.auth.domain.entity.SysDept;
import com.crm.auth.domain.entity.SysUserDept;
import com.crm.auth.mapper.AuthIdentityMapper;
import com.crm.auth.mapper.AuthUserMapper;
import com.crm.auth.mapper.SysUserDeptMapper;
import com.crm.auth.service.ISysDeptService;
import com.github.tingyugetc520.ali.dingtalk.api.DtService;
import com.github.tingyugetc520.ali.dingtalk.bean.department.DtDepart;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* {@link DtOrgSyncServiceImpl} 单元测试票据 03
* 通过覆写 fetchDepartments / fetchDeptUsersRaw seam 注入桩数据
* 验证部门/用户幂等 upsert主兼职部门拆分身份绑定与负责人反写
*/
@ExtendWith(MockitoExtension.class)
class DtOrgSyncServiceImplTest {
static {
// 无 Spring/MyBatis 容器时手动注册实体元数据,LambdaQueryWrapper 解析列名依赖 TableInfo
MybatisConfiguration configuration = new MybatisConfiguration();
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), AuthUser.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), AuthIdentity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), SysUserDept.class);
}
@Mock
private DtService dtService;
@Mock
private ISysDeptService sysDeptService;
@Mock
private AuthUserMapper authUserMapper;
@Mock
private AuthIdentityMapper authIdentityMapper;
@Mock
private SysUserDeptMapper sysUserDeptMapper;
@Mock
private StringRedisTemplate stringRedisTemplate;
/** 内存部门库:本地ID -> 实体 */
private final Map<Long, SysDept> deptStore = new HashMap<>();
/** 内存用户库:account -> 实体 */
private final Map<String, AuthUser> userStore = new HashMap<>();
/** 内存身份库:unionId -> 实体 */
private final Map<String, AuthIdentity> identityStore = new HashMap<>();
private final AtomicLong idGen = new AtomicLong(1000);
/** 桩:钉钉部门(乱序提供,验证 BFS 拓扑) */
private List<DtDepart> stubDepts = new ArrayList<>();
/** 桩:钉钉部门ID -> 该部门用户 */
private Map<Long, List<DtOrgSyncServiceImpl.DtUserRaw>> stubUsers = new HashMap<>();
private DtOrgSyncServiceImpl service;
@BeforeEach
void setUp() {
service = new DtOrgSyncServiceImpl(dtService, sysDeptService, authUserMapper,
authIdentityMapper, sysUserDeptMapper, stringRedisTemplate) {
@Override
protected List<DtDepart> fetchDepartments() {
return stubDepts;
}
@Override
protected List<DtUserRaw> fetchDeptUsersRaw(Long dtDeptId, int offset, int size) {
// 桩数据单页即可
return offset == 0 ? stubUsers.getOrDefault(dtDeptId, List.of()) : List.of();
}
};
// 部门 upsert:saveOrUpdate 模拟数据库回填主键(lenient:部分用例不会命中全部桩)
lenient().when(sysDeptService.list()).thenAnswer(inv -> new ArrayList<>(deptStore.values()));
lenient().when(sysDeptService.saveOrUpdate(any(SysDept.class))).thenAnswer(inv -> {
SysDept d = inv.getArgument(0);
if (d.getId() == null) {
d.setId(idGen.incrementAndGet());
}
deptStore.put(d.getId(), d);
return true;
});
lenient().when(sysDeptService.updateById(any(SysDept.class))).thenAnswer(inv -> {
SysDept d = inv.getArgument(0);
deptStore.put(d.getId(), d);
return true;
});
lenient().when(sysDeptService.getById(any())).thenAnswer(inv -> deptStore.get((Long) inv.getArgument(0)));
// 用户 upsert:按 account 查、insert 回填主键
lenient().when(authUserMapper.selectOne(any())).thenAnswer(inv -> {
LambdaQueryWrapper<AuthUser> w = inv.getArgument(0);
w.getSqlSegment(); // MP 参数值惰性登记,先触发再取
Object account = w.getParamNameValuePairs().values().iterator().next();
return userStore.get(String.valueOf(account));
});
lenient().when(authUserMapper.insert(any(AuthUser.class))).thenAnswer(inv -> {
AuthUser u = inv.getArgument(0);
u.setId(idGen.incrementAndGet());
userStore.put(u.getAccount(), u);
return 1;
});
lenient().when(authUserMapper.updateById(any(AuthUser.class))).thenAnswer(inv -> {
AuthUser u = inv.getArgument(0);
userStore.put(u.getAccount(), u);
return 1;
});
// 身份绑定:按 unionId 查、insert 回填主键
lenient().when(authIdentityMapper.selectOne(any())).thenAnswer(inv -> {
LambdaQueryWrapper<AuthIdentity> w = inv.getArgument(0);
w.getSqlSegment(); // MP 参数值惰性登记,先触发再取
Object unionId = w.getParamNameValuePairs().values().stream()
.filter(v -> v instanceof String s && s.startsWith("union"))
.findFirst().orElse(null);
return identityStore.get(unionId);
});
lenient().when(authIdentityMapper.insert(any(AuthIdentity.class))).thenAnswer(inv -> {
AuthIdentity i = inv.getArgument(0);
i.setId(idGen.incrementAndGet());
identityStore.put(i.getUnionId(), i);
return 1;
});
}
// ==================== 桩数据构造 ====================
private static DtDepart dtDepart(long id, Long parentId, String name) {
DtDepart d = new DtDepart();
d.setId(id);
d.setParentId(parentId);
d.setName(name);
d.setOrder(id * 10L);
return d;
}
private static DtOrgSyncServiceImpl.DtUserRaw dtUser(String userid, String name, String unionId,
Map<Long, Boolean> leaderInDepts) {
DtOrgSyncServiceImpl.DtUserRaw u = new DtOrgSyncServiceImpl.DtUserRaw();
u.setUserid(userid);
u.setName(name);
u.setUnionid(unionId);
u.setMobile("1380000" + userid.hashCode() % 10000);
u.setActive(true);
u.setIsLeaderInDepts(leaderInDepts);
return u;
}
/** 部门树:1(根)─ 2 ─ 3;用户 u1 在 1、u2 在 1 和 2(兼职)、u3 在 3 且为 3 的负责人 */
private void arrangeOrg() {
stubDepts = new ArrayList<>(List.of(
dtDepart(3L, 2L, "三级部"), // 故意乱序
dtDepart(1L, null, "总公司"),
dtDepart(2L, 1L, "二级部")));
stubUsers = new HashMap<>();
stubUsers.put(1L, List.of(
dtUser("u1", "张三", "union1", null),
dtUser("u2", "李四", "union2", null)));
stubUsers.put(2L, List.of(dtUser("u2", "李四", "union2", null)));
stubUsers.put(3L, List.of(dtUser("u3", "王五", "union3", Map.of(3L, true))));
}
private SysDept deptByDtId(long dtDeptId) {
return deptStore.values().stream()
.filter(d -> Long.valueOf(dtDeptId).equals(d.getDtDeptId()))
.findFirst().orElseThrow();
}
// ==================== 用例 ====================
@Test
@DisplayName("首装全量同步:部门建 3、用户建 3、兼职拆分、身份绑定、负责人反写")
void firstSync_insertsEverything() {
arrangeOrg();
DtSyncResultDTO result = service.syncOrg();
assertThat(result.getDeptTotal()).isEqualTo(3);
assertThat(result.getDeptInserted()).isEqualTo(3);
assertThat(result.getUserTotal()).isEqualTo(3);
assertThat(result.getUserInserted()).isEqualTo(3);
assertThat(result.getLeaderAssigned()).isEqualTo(1);
// 部门父子映射正确:三级部的父 = 二级部本地ID
SysDept dept3 = deptByDtId(3L);
SysDept dept2 = deptByDtId(2L);
assertThat(dept3.getParentId()).isEqualTo(dept2.getId());
assertThat(deptByDtId(1L).getParentId()).isEqualTo(0L);
// u2 主部门为 1(BFS 序靠根优先),兼职挂 2
AuthUser u2 = userStore.get("u2");
assertThat(u2.getDeptId()).isEqualTo(deptByDtId(1L).getId());
ArgumentCaptor<SysUserDept> linkCaptor = ArgumentCaptor.forClass(SysUserDept.class);
verify(sysUserDeptMapper, times(1)).insert(linkCaptor.capture());
assertThat(linkCaptor.getValue().getDeptId()).isEqualTo(dept2.getId());
assertThat(linkCaptor.getValue().getUserId()).isEqualTo(u2.getId());
// 身份绑定:3 条 unionId
verify(authIdentityMapper, times(3)).insert(any(AuthIdentity.class));
// 负责人反写:三级部 leaderUserId = u3 本地ID
assertThat(dept3.getLeaderUserId()).isEqualTo(userStore.get("u3").getId());
}
@Test
@DisplayName("重跑幂等:部门/用户零新增,改名可更新")
void resync_idempotent_andUpdatesName() {
arrangeOrg();
service.syncOrg();
// 钉钉侧改名
stubDepts = List.of(
dtDepart(1L, null, "集团总部"),
dtDepart(2L, 1L, "二级部"),
dtDepart(3L, 2L, "三级部"));
stubUsers.put(1L, List.of(
dtUser("u1", "张三丰", "union1", null),
dtUser("u2", "李四", "union2", null)));
DtSyncResultDTO result = service.syncOrg();
assertThat(result.getDeptInserted()).isZero();
assertThat(result.getDeptUpdated()).isEqualTo(1);
assertThat(result.getUserInserted()).isZero();
assertThat(result.getUserUpdated()).isEqualTo(3);
assertThat(deptByDtId(1L).getDeptName()).isEqualTo("集团总部");
assertThat(userStore.get("u1").getUsername()).isEqualTo("张三丰");
// 部门与用户总数不膨胀
assertThat(deptStore).hasSize(3);
assertThat(userStore).hasSize(3);
// 身份绑定仅在首装插入 3 条,重跑不再新增
assertThat(identityStore).hasSize(3);
verify(authIdentityMapper, times(3)).insert(any(AuthIdentity.class));
}
@Test
@DisplayName("离职用户同步为 resigned,不动 enabled")
void inactiveUser_markedResigned() {
stubDepts = List.of(dtDepart(1L, null, "总公司"));
DtOrgSyncServiceImpl.DtUserRaw inactive = dtUser("u9", "离职哥", "union9", null);
inactive.setActive(false);
stubUsers.put(1L, List.of(inactive));
service.syncOrg();
AuthUser u9 = userStore.get("u9");
assertThat(u9.getEmploymentStatus()).isEqualTo("resigned");
assertThat(u9.getEnabled()).isTrue();
}
}

2
crm-auth/src/test/java/com/crm/auth/service/impl/UserListIntegrationTest.java

@ -348,7 +348,7 @@ class UserListIntegrationTest {
create table sys_dept (
id bigint primary key, creator_id varchar(50), create_time datetime,
updater_id varchar(50), update_time datetime, deleted tinyint not null default 0,
parent_id bigint, dept_name varchar(50), ancestors varchar(500), sort int, leader_user_id bigint)
parent_id bigint, dept_name varchar(50), ancestors varchar(500), sort int, leader_user_id bigint, dt_dept_id bigint)
""",
// 部门树
"insert into sys_dept (id, deleted, parent_id, dept_name) values (10, 0, 0, '销售部')",

Loading…
Cancel
Save