From e4b2a320ea4182fcbb38c33c855c33d4da7452c6 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Mon, 17 Aug 2026 23:35:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=92=89=E9=92=89=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=AB=AF=E7=82=B9=20+=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E8=A7=92=E8=89=B2=E9=85=8D=E7=BD=AE=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E2=80=94=E2=80=94dt-java=20=E9=83=A8=E9=97=A8=E6=A0=91/HTTP=20?= =?UTF-8?q?listbypage=20=E7=94=A8=E6=88=B7=E5=B9=82=E7=AD=89=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=8Cdt=5Fdept=5Fid=20=E4=B8=8E=20account=20?= =?UTF-8?q?=E4=BD=9C=E5=B9=82=E7=AD=89=E9=94=AE=EF=BC=8C=E8=B4=9F=E8=B4=A3?= =?UTF-8?q?=E4=BA=BA=E5=8F=8D=E5=86=99=E4=B8=8D=E6=98=A0=E5=B0=84=E8=A7=92?= =?UTF-8?q?=E8=89=B2=EF=BC=88frontend-integration=20=E7=A5=A8=E6=8D=AE=200?= =?UTF-8?q?3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../issues/01-region-seed.md | 32 ++ .../issues/02-dict-seed-align.md | 35 ++ .../issues/03-dingtalk-sync-endpoint.md | 37 ++ .../issues/04-dtjava-sdk-research.md | 80 +++ .../issues/05-testdata-seeding.md | 32 ++ .../issues/06-static-demo.md | 19 + .../issues/07-api-audit-against-prototype.md | 21 + .scratch/frontend-integration/map.md | 45 ++ .../frontend-integration/seed-testdata.py | 266 +++++++++ crm-auth/pom.xml | 14 + .../com/crm/auth/config/DtClientConfig.java | 36 ++ .../com/crm/auth/constant/AuthConstants.java | 6 + .../crm/auth/controller/SystemController.java | 29 + .../domain/dto/ConfigureAdminRoleDTO.java | 29 + .../crm/auth/domain/dto/DtSyncResultDTO.java | 31 ++ .../com/crm/auth/domain/entity/SysDept.java | 8 +- .../crm/auth/service/IAuthUserService.java | 12 + .../crm/auth/service/IDtOrgSyncService.java | 19 + .../service/impl/AuthUserServiceImpl.java | 51 ++ .../service/impl/DtOrgSyncServiceImpl.java | 523 ++++++++++++++++++ .../scope/DataScopeIntegrationTest.java | 2 +- .../service/impl/AuthUserServiceImplTest.java | 78 +++ .../impl/DtOrgSyncServiceImplTest.java | 273 +++++++++ .../service/impl/UserListIntegrationTest.java | 2 +- 24 files changed, 1677 insertions(+), 3 deletions(-) create mode 100644 .scratch/frontend-integration/issues/01-region-seed.md create mode 100644 .scratch/frontend-integration/issues/02-dict-seed-align.md create mode 100644 .scratch/frontend-integration/issues/03-dingtalk-sync-endpoint.md create mode 100644 .scratch/frontend-integration/issues/04-dtjava-sdk-research.md create mode 100644 .scratch/frontend-integration/issues/05-testdata-seeding.md create mode 100644 .scratch/frontend-integration/issues/06-static-demo.md create mode 100644 .scratch/frontend-integration/issues/07-api-audit-against-prototype.md create mode 100644 .scratch/frontend-integration/map.md create mode 100644 .scratch/frontend-integration/seed-testdata.py create mode 100644 crm-auth/src/main/java/com/crm/auth/config/DtClientConfig.java create mode 100644 crm-auth/src/main/java/com/crm/auth/domain/dto/ConfigureAdminRoleDTO.java create mode 100644 crm-auth/src/main/java/com/crm/auth/domain/dto/DtSyncResultDTO.java create mode 100644 crm-auth/src/main/java/com/crm/auth/service/IDtOrgSyncService.java create mode 100644 crm-auth/src/main/java/com/crm/auth/service/impl/DtOrgSyncServiceImpl.java create mode 100644 crm-auth/src/test/java/com/crm/auth/service/impl/DtOrgSyncServiceImplTest.java diff --git a/.scratch/frontend-integration/issues/01-region-seed.md b/.scratch/frontend-integration/issues/01-region-seed.md new file mode 100644 index 0000000..738b9d4 --- /dev/null +++ b/.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`(原本违反「所有业务 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 需显式设置)。 diff --git a/.scratch/frontend-integration/issues/02-dict-seed-align.md b/.scratch/frontend-integration/issues/02-dict-seed-align.md new file mode 100644 index 0000000..3f0a387 --- /dev/null +++ b/.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 残留,待联调库重建或手工停用,未写迁移逻辑)。 diff --git a/.scratch/frontend-integration/issues/03-dingtalk-sync-endpoint.md b/.scratch/frontend-integration/issues/03-dingtalk-sync-endpoint.md new file mode 100644 index 0000000..d1c1140 --- /dev/null +++ b/.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` 即可,无需改代码。 diff --git a/.scratch/frontend-integration/issues/04-dtjava-sdk-research.md b/.scratch/frontend-integration/issues/04-dtjava-sdk-research.md new file mode 100644 index 0000000..59331f8 --- /dev/null +++ b/.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 depts = dtService.getDepartmentService().list(null, true); // 递归全量 +``` + +`DtDepart` 字段仅 `id / name / parentId / order`——**没有 dept_manager_userid**。 + +### 4. 部门负责人:从用户侧反查(关键修正) + +部门负责人不在 DtDepart,而在 `DtUser.isLeaderInDeparts: Map`(获取部门用户详情接口返回)。同步逻辑应为:拉用户时聚合 `isLeaderInDeparts`,反写对应部门的负责人字段。 + +### 5. 用户:`DtUserService` + +```java +// 部门用户详情(offset/size 分页,循环拉到不足一页为止) +List 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 线程 + 限流) diff --git a/.scratch/frontend-integration/issues/05-testdata-seeding.md b/.scratch/frontend-integration/issues/05-testdata-seeding.md new file mode 100644 index 0000000..37b3142 --- /dev/null +++ b/.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)重同步真实用户再补铺;同步后也建议重跑本脚本核对幂等。 diff --git a/.scratch/frontend-integration/issues/06-static-demo.md b/.scratch/frontend-integration/issues/06-static-demo.md new file mode 100644 index 0000000..6f4024a --- /dev/null +++ b/.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/`。 diff --git a/.scratch/frontend-integration/issues/07-api-audit-against-prototype.md b/.scratch/frontend-integration/issues/07-api-audit-against-prototype.md new file mode 100644 index 0000000..f0acf46 --- /dev/null +++ b/.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」毕业立票。 diff --git a/.scratch/frontend-integration/map.md b/.scratch/frontend-integration/map.md new file mode 100644 index 0000000..da33657 --- /dev/null +++ b/.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。 diff --git a/.scratch/frontend-integration/seed-testdata.py b/.scratch/frontend-integration/seed-testdata.py new file mode 100644 index 0000000..690feb8 --- /dev/null +++ b/.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() diff --git a/crm-auth/pom.xml b/crm-auth/pom.xml index 5d82910..420b76a 100644 --- a/crm-auth/pom.xml +++ b/crm-auth/pom.xml @@ -34,6 +34,20 @@ spring-boot-starter-security + + + com.github.tingyugetc520 + dt-java + 0.1.2 + + + + com.thoughtworks.xstream + xstream + + + + com.mysql diff --git a/crm-auth/src/main/java/com/crm/auth/config/DtClientConfig.java b/crm-auth/src/main/java/com/crm/auth/config/DtClientConfig.java new file mode 100644 index 0000000..e81b685 --- /dev/null +++ b/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; + +/** + * 钉钉开放平台 SDK(dt-java)客户端配置:组织同步(部门树 + 用户)使用。 + *

复用 {@code crm.auth.dingtalk.client-id/client-secret}(即钉钉 AppKey/AppSecret); + * SDK 内部管理企业级 access token 的获取与自动刷新,部门/用户链路无需 corpId/agentId。

+ */ +@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; + } +} diff --git a/crm-auth/src/main/java/com/crm/auth/constant/AuthConstants.java b/crm-auth/src/main/java/com/crm/auth/constant/AuthConstants.java index b57d42e..0abb51f 100644 --- a/crm-auth/src/main/java/com/crm/auth/constant/AuthConstants.java +++ b/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"; } diff --git a/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java b/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java index 115863a..f8d2f68 100644 --- a/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java +++ b/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 syncDingtalk() { + return Result.success(dtOrgSyncService.syncOrg()); + } + + /** 管理员角色配置(票据 03):给指定用户增量授予/回收管理员类角色,不影响其他角色 */ + @PostMapping("/users/admin-role") + @PreAuthorize("hasRole('ADMIN')") + public Result configureAdminRole(ConfigureAdminRoleDTO dto) { + List 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 splitIds(String ids) { return StrUtil.isBlank(ids) diff --git a/crm-auth/src/main/java/com/crm/auth/domain/dto/ConfigureAdminRoleDTO.java b/crm-auth/src/main/java/com/crm/auth/domain/dto/ConfigureAdminRoleDTO.java new file mode 100644 index 0000000..9f3a6d0 --- /dev/null +++ b/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; + +/** + * 管理员角色配置入参(票据 03;ADR-0017:纯 POJO,无主键语义故不继承 BaseDTO) + *

给指定用户增量授予/回收管理员类角色(只动目标角色行,不影响其他角色), + * 解决「钉钉同步进来的用户如何拿到角色」。roleCodes 为逗号分隔的角色编码串, + * 由 Controller 拆分;缺省为 ROLE_ADMIN。

+ */ +@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; +} diff --git a/crm-auth/src/main/java/com/crm/auth/domain/dto/DtSyncResultDTO.java b/crm-auth/src/main/java/com/crm/auth/domain/dto/DtSyncResultDTO.java new file mode 100644 index 0000000..8f5813f --- /dev/null +++ b/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; +} diff --git a/crm-auth/src/main/java/com/crm/auth/domain/entity/SysDept.java b/crm-auth/src/main/java/com/crm/auth/domain/entity/SysDept.java index f213d0e..9a7b5cd 100644 --- a/crm-auth/src/main/java/com/crm/auth/domain/entity/SysDept.java +++ b/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 构建树时填充 */ diff --git a/crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java b/crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java index b4a86b1..0c16284 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java +++ b/crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java @@ -30,4 +30,16 @@ public interface IAuthUserService extends IBaseService { * @param roleIds 角色 ID 列表 */ void assignRoles(Long userId, List roleIds); + + /** + * 管理员角色配置(票据 03):给指定用户增量授予/回收管理员类角色。 + *

与 {@link #assignRoles} 的全量替换不同,本方法只动目标角色行, + * 不影响用户已有的其他角色(部门负责人/销售角色仍由角色管理体系承载), + * 专门解决「钉钉同步进来的用户如何拿到角色」。

+ * + * @param userId 目标用户 ID + * @param roleCodes 角色编码列表(如 ROLE_ADMIN),必须全部存在 + * @param grant true=授予(幂等),false=回收(无则忽略) + */ + void configureAdminRoles(Long userId, List roleCodes, boolean grant); } diff --git a/crm-auth/src/main/java/com/crm/auth/service/IDtOrgSyncService.java b/crm-auth/src/main/java/com/crm/auth/service/IDtOrgSyncService.java new file mode 100644 index 0000000..2d8db62 --- /dev/null +++ b/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) + *

全量拉取钉钉部门树与用户,幂等 upsert 到 sys_dept / crm_auth_user, + * 并把钉钉「部门负责人」字段反写到 sys_dept.leader_user_id。

+ */ +public interface IDtOrgSyncService { + + /** + * 执行一次全量组织同步(同步阻塞,仅超管通过端点触发)。 + *

幂等:重跑不产生重复数据;钉钉侧改名/调部门可在下一次同步中更新。

+ * + * @return 本次同步的统计结果 + */ + DtSyncResultDTO syncOrg(); +} diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/AuthUserServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/AuthUserServiceImpl.java index c2dc905..4247280 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/impl/AuthUserServiceImpl.java +++ b/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 roleCodes, boolean grant) { + // 参数校验先于查库,避免非法参数也打一次 DB + if (userId == null) { + throw new BusinessErrorException(AuthConstants.CODE_ROLE_INVALID, "用户ID不能为空"); + } + List 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 roles = sysRoleMapper.selectList( + new LambdaQueryWrapper().in(SysRole::getRoleCode, codes)); + Map idByCode = roles.stream() + .collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getId, (a, b) -> a)); + List 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() + .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() + .eq(SysUserRole::getUserId, userId) + .eq(SysUserRole::getRoleId, roleId)); + } + } + } } diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/DtOrgSyncServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/DtOrgSyncServiceImpl.java new file mode 100644 index 0000000..ff04891 --- /dev/null +++ b/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)。 + *

流程:

+ *
    + *
  1. dt-java SDK {@code DtDepartmentService.list(null, true)} 一次性拉全量部门树, + * 按 dt_dept_id 幂等 upsert 到 sys_dept(BFS 保证父部门先落库,祖级链复用 {@link ISysDeptService#saveOrUpdate})
  2. + *
  3. 逐部门分页拉用户详情(旧版 oapi {@code /topapi/user/listbypage},HTTP 直调,与 DingTalkAuthClient 同款手法), + * 按 account=钉钉 userid 幂等 upsert 到 crm_auth_user;首个出现的部门记为主部门,其余为 sys_user_dept 兼职挂靠(全量替换)
  4. + *
  5. 聚合响应中的 isLeaderInDepts(钉钉的部门负责人是部门维度字段,不是系统角色),反写 sys_dept.leader_user_id。 + * 注意:dt-java 0.1.2 发布版 DtUser 不含该字段,因此用户链路不用 SDK bean 而直接解析 JSON
  6. + *
  7. unionId 写入 crm_auth_identity(dingtalk + union_id 唯一),保证后续扫码登录映射到同步下来的账号
  8. + *
+ *

可测试性:{@link #fetchDepartments()} 与 {@link #fetchDeptUsersRaw(Long, int, int)} 为外部调用 seam, + * 单元测试通过继承覆写注入桩数据。

+ */ +@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 dtDepts = orderByBreadthFirst(fetchDepartments()); + result.setDeptTotal(dtDepts.size()); + Map dtDeptIdToLocalId = syncDepartments(dtDepts, result); + + // ===== 2. 用户(主/兼职部门 + 身份绑定) ===== + Map dtUserByUserId = new LinkedHashMap<>(); + Map 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 orderByBreadthFirst(List dtDepts) { + Map byId = new LinkedHashMap<>(); + for (DtDepart d : dtDepts) { + byId.put(d.getId(), d); + } + Map> childrenByParent = new LinkedHashMap<>(); + List 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 ordered = new ArrayList<>(dtDepts.size()); + Deque queue = new ArrayDeque<>(roots); + while (!queue.isEmpty()) { + DtDepart d = queue.pollFirst(); + ordered.add(d); + List 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 syncDepartments(List dtDepts, DtSyncResultDTO result) { + Map existingByDtId = new HashMap<>(); + for (SysDept dept : sysDeptService.list()) { + if (dept.getDtDeptId() != null) { + existingByDtId.put(dept.getDtDeptId(), dept); + } + } + Map 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 syncUsers(List dtDepts, Map dtDeptIdToLocalId, + Map dtUserByUserId, DtSyncResultDTO result) { + // userId -> 本地部门 id 列表(首个为主部门),LinkedHashMap 保持部门出现顺序 + Map> 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 synced = new LinkedHashMap<>(); + for (Map.Entry entry : dtUserByUserId.entrySet()) { + DtUserRaw dtUser = entry.getValue(); + List 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().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() + .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 localDeptIds) { + sysUserDeptMapper.delete(new LambdaQueryWrapper().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 dtUserByUserId, Map syncedUsers, + Map dtDeptIdToLocalId, DtSyncResultDTO result) { + Map leaderByLocalDeptId = new LinkedHashMap<>(); + for (Map.Entry 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 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 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 fetchDepartments() throws DtErrorException { + List depts = dtService.getDepartmentService().list(null, true); + return depts == null ? Collections.emptyList() : depts; + } + + /** + * 分页拉取指定部门下的全部用户详情(单页上限 100,自行翻页)。 + * protected:单测可覆写注入桩数据 + */ + protected List fetchDeptUsers(Long dtDeptId) { + List all = new ArrayList<>(); + int offset = 0; + while (true) { + List 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 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 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 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 isLeaderInDepts; + } +} diff --git a/crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java b/crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java index 134b462..e9ee156 100644 --- a/crm-auth/src/test/java/com/crm/auth/security/scope/DataScopeIntegrationTest.java +++ b/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 ( diff --git a/crm-auth/src/test/java/com/crm/auth/service/impl/AuthUserServiceImplTest.java b/crm-auth/src/test/java/com/crm/auth/service/impl/AuthUserServiceImplTest.java index 7f57c42..8b2c59a 100644 --- a/crm-auth/src/test/java/com/crm/auth/service/impl/AuthUserServiceImplTest.java +++ b/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); + } } diff --git a/crm-auth/src/test/java/com/crm/auth/service/impl/DtOrgSyncServiceImplTest.java b/crm-auth/src/test/java/com/crm/auth/service/impl/DtOrgSyncServiceImplTest.java new file mode 100644 index 0000000..9485562 --- /dev/null +++ b/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 deptStore = new HashMap<>(); + /** 内存用户库:account -> 实体 */ + private final Map userStore = new HashMap<>(); + /** 内存身份库:unionId -> 实体 */ + private final Map identityStore = new HashMap<>(); + private final AtomicLong idGen = new AtomicLong(1000); + + /** 桩:钉钉部门(乱序提供,验证 BFS 拓扑) */ + private List stubDepts = new ArrayList<>(); + /** 桩:钉钉部门ID -> 该部门用户 */ + private Map> stubUsers = new HashMap<>(); + + private DtOrgSyncServiceImpl service; + + @BeforeEach + void setUp() { + service = new DtOrgSyncServiceImpl(dtService, sysDeptService, authUserMapper, + authIdentityMapper, sysUserDeptMapper, stringRedisTemplate) { + @Override + protected List fetchDepartments() { + return stubDepts; + } + + @Override + protected List 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 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 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 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 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(); + } +} diff --git a/crm-auth/src/test/java/com/crm/auth/service/impl/UserListIntegrationTest.java b/crm-auth/src/test/java/com/crm/auth/service/impl/UserListIntegrationTest.java index 70f9d94..a07b008 100644 --- a/crm-auth/src/test/java/com/crm/auth/service/impl/UserListIntegrationTest.java +++ b/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, '销售部')",