24 changed files with 1677 additions and 3 deletions
@ -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 需显式设置)。 |
||||
@ -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 残留,待联调库重建或手工停用,未写迁移逻辑)。 |
||||
@ -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` 即可,无需改代码。 |
||||
@ -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 线程 + 限流) |
||||
@ -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)重同步真实用户再补铺;同步后也建议重跑本脚本核对幂等。 |
||||
@ -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/`。 |
||||
@ -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」毕业立票。 |
||||
@ -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。 |
||||
@ -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() |
||||
@ -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; |
||||
|
|
||||
|
/** |
||||
|
* 钉钉开放平台 SDK(dt-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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
|
||||
|
/** |
||||
|
* 管理员角色配置入参(票据 03;ADR-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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
@ -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_dept(BFS 保证父部门先落库,祖级链复用 {@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_identity(dingtalk + 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; |
||||
|
} |
||||
|
|
||||
|
// ==================== 用户 ====================
|
||||
|
|
||||
|
/** |
||||
|
* 逐部门分页拉用户并幂等 upsert;BFS 序保证首个出现的部门(越靠近根越优先)作为主部门。 |
||||
|
* 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); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ==================== 部门负责人 ====================
|
||||
|
|
||||
|
/** |
||||
|
* 钉钉部门负责人存在用户详情的 isLeaderInDepts(Map<部门ID, 是否负责人>)里, |
||||
|
* 是部门维度字段而非系统角色;聚合后反写 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 SDK(SDK 自管 token)。protected:单测可覆写注入桩数据 |
||||
|
*/ |
||||
|
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 直调旧版 oapi(dt-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; |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue