ソースを参照

refactor(modules): migrate harrison and guangming

malk 2 週間 前
コミット
7edff7a436
共有56 個のファイルを変更した2591 個の追加19 個の削除を含む
  1. 14 11
      CLAUDE.md
  2. 27 8
      README.md
  3. 51 0
      mjava-guangming/pom.xml
  4. 23 0
      mjava-guangming/src/main/java/com/malk/guangming/Boot.java
  5. 32 0
      mjava-guangming/src/main/java/com/malk/guangming/config/GuangmingConfig.java
  6. 38 0
      mjava-guangming/src/main/java/com/malk/guangming/config/MappingTableNamingStrategy.java
  7. 127 0
      mjava-guangming/src/main/java/com/malk/guangming/controller/AppRoleSyncController.java
  8. 77 0
      mjava-guangming/src/main/java/com/malk/guangming/controller/MailSsoController.java
  9. 24 0
      mjava-guangming/src/main/java/com/malk/guangming/dto/AppPreviewDto.java
  10. 21 0
      mjava-guangming/src/main/java/com/malk/guangming/dto/AppSyncResultDto.java
  11. 13 0
      mjava-guangming/src/main/java/com/malk/guangming/dto/UsersUpdateDto.java
  12. 24 0
      mjava-guangming/src/main/java/com/malk/guangming/repository/dao/DingTalkAppMappingDao.java
  13. 82 0
      mjava-guangming/src/main/java/com/malk/guangming/repository/entity/primary/DingTalkAppMappingPo.java
  14. 712 0
      mjava-guangming/src/main/java/com/malk/guangming/service/AppRoleSyncService.java
  15. 62 0
      mjava-guangming/src/main/java/com/malk/guangming/service/DingTalkAuthService.java
  16. 44 0
      mjava-guangming/src/main/java/com/malk/guangming/util/RSACrypt.java
  17. 17 0
      mjava-guangming/src/main/resources/application-dev.yml.example
  18. 17 0
      mjava-guangming/src/main/resources/application-prod.yml.example
  19. 27 0
      mjava-guangming/src/main/resources/application.yml
  20. 138 0
      mjava-guangming/src/main/resources/static/sso.html
  21. 37 0
      mjava-guangming/src/test/java/com/malk/guangming/config/GuangmingConfigTest.java
  22. 54 0
      mjava-harrison/pom.xml
  23. 32 0
      mjava-harrison/src/main/java/com/malk/harrison/Boot.java
  24. 31 0
      mjava-harrison/src/main/java/com/malk/harrison/config/HarrisonConfig.java
  25. 22 0
      mjava-harrison/src/main/java/com/malk/harrison/controller/DDController.java
  26. 115 0
      mjava-harrison/src/main/java/com/malk/harrison/controller/HLController.java
  27. 142 0
      mjava-harrison/src/main/java/com/malk/harrison/controller/SDSController.java
  28. 247 0
      mjava-harrison/src/main/java/com/malk/harrison/controller/XYController.java
  29. 146 0
      mjava-harrison/src/main/java/com/malk/harrison/entity/XyTodo.java
  30. 30 0
      mjava-harrison/src/main/java/com/malk/harrison/server/XYR.java
  31. 25 0
      mjava-harrison/src/main/resources/application-dev.yml.example
  32. 25 0
      mjava-harrison/src/main/resources/application-prod.yml.example
  33. 39 0
      mjava-harrison/src/main/resources/application.yml
  34. BIN
      mjava-harrison/src/main/resources/templates/FLOURISH 销售发票.xlsx
  35. BIN
      mjava-harrison/src/main/resources/templates/HARRISON 海运-物流服务.xlsx
  36. BIN
      mjava-harrison/src/main/resources/templates/HARRISON 贸易-吨度.xlsx
  37. BIN
      mjava-harrison/src/main/resources/templates/HARRISON 贸易-湿吨.xlsx
  38. BIN
      mjava-harrison/src/main/resources/templates/HARRISON 贸易-磅价.xlsx
  39. BIN
      mjava-harrison/src/main/resources/templates/HARRISON 通用-加工费.xlsx
  40. BIN
      mjava-harrison/src/main/resources/templates/HARRISON 通用模板.xlsx
  41. BIN
      mjava-harrison/src/main/resources/templates/VIC 贸易-吨度.xlsx
  42. BIN
      mjava-harrison/src/main/resources/templates/VIC 贸易-干吨价.xlsx
  43. BIN
      mjava-harrison/src/main/resources/templates/VIC 贸易-磅价.xlsx
  44. BIN
      mjava-harrison/src/main/resources/templates/VIC 通用-联检费.xlsx
  45. BIN
      mjava-harrison/src/main/resources/templates/VIC 通用模板.xlsx
  46. BIN
      mjava-harrison/src/main/resources/templates/VLINK 海运-拖车费.xlsx
  47. BIN
      mjava-harrison/src/main/resources/templates/VLINK 贸易-佣金.xlsx
  48. BIN
      mjava-harrison/src/main/resources/templates/VLINK 贸易-加工费.xlsx
  49. BIN
      mjava-harrison/src/main/resources/templates/VLINK 贸易-尾矿.xlsx
  50. BIN
      mjava-harrison/src/main/resources/templates/VLINK 贸易-干吨价.xlsx
  51. BIN
      mjava-harrison/src/main/resources/templates/VLINK 贸易-湿吨.xlsx
  52. BIN
      mjava-harrison/src/main/resources/templates/VLINK 通用模板.xlsx
  53. BIN
      mjava-harrison/src/main/resources/templates/ZEEK 物流-拖车费.xlsx
  54. 39 0
      mjava-harrison/src/test/java/com/malk/harrison/config/HarrisonConfigTest.java
  55. 35 0
      mjava-harrison/src/test/resources/server.sh
  56. 2 0
      pom.xml

+ 14 - 11
CLAUDE.md

@@ -1,17 +1,20 @@
 # mjava-ai(程序仓)
 
-本仓库仅保留 Java 程序代码(pom + src + yml.example)。
-
-**所有开发设计规范 / OpenSpec changes & specs / 子项目说明 / opsx commands & skills 已迁出到文档中心**:
+本仓库保存 Java 源码和 Maven 构建配置。开发规范与客户事项资料位于:
 
 ```
-/Users/malk/Desktop/Tech/claude/后端/mjava-ai/
-├── CLAUDE.md            ← AI 协作主入口(含 opsx 工作流、change 状态、严禁清单)
-├── README.md            ← 完整项目说明
-├── openspec/            ← changes / specs / archive / BACKLOG
-├── .claude/             ← opsx commands + openspec skills
-├── mjava-pro/README.md
-└── mjava-com/README.md
+/Users/malk/Desktop/Tech/1. malk/后端/
+├── AGENTS.md            ← 后端开发规范主入口
+├── CLAUDE.md            ← AGENTS.md 的 Claude 兼容入口
+├── _mjava/
+│   ├── README.md                 ← 模块边界与目录映射
+│   └── 2 one/<客户>/<场景>/      ← mjava-ai 单客户子项目资料
+└── <独立项目>/
+    ├── <场景>/                    ← 独立 Java 工程事项资料
+    └── 程序 -> /源码仓库          ← 源码快捷方式
 ```
 
-后端通用规范见 `/Users/malk/Desktop/Tech/claude/后端/CLAUDE.md`(含 Client/Service 分层规则 R1~R7)。
+后端通用规范见 `/Users/malk/Desktop/Tech/1. malk/后端/CLAUDE.md`(含 Client/Service 分层规则 R1~R7)。
+
+当前聚合模块、配置约定、构建命令及独立项目边界以本仓库 `README.md` 为准。
+钉钉角色与应用可见范围能力统一由 `mjava` 的 `DDClient_Role` 提供。

+ 27 - 8
README.md

@@ -1,28 +1,47 @@
 # mjava-ai
 
-Java 后端基座 + 客户子项目仓库。Spring Boot 2.2.13 + MySQL。
+Java 后端基座、公共运行时、标准接口和客户子项目的统一源码仓库。Spring Boot 2.2.13 + MySQL。
 
 ## 模块
 
 | 模块 | 用途 |
 |---|---|
 | `mjava/` | 基座 jar(被所有子项目依赖;不可独立运行) |
-| `mjava-mcli/` | 客户模板(端口 9001) |
-| `mjava-pro/` | 多租户单部署运行时(A 档公共托管,端口 9010) |
-| `mjava-com/` | BaaS 网关(对外暴露 mjava 能力,端口 9020) |
+| `mjava-mcli/` | 个人技术验证、接口调试和客户模块模板(端口 9001) |
+| `mjava-pro/` | 公共项目 / 多租户单部署运行时(端口 9010) |
+| `mjava-com/` | 将基础能力封装为标准接口的 BaaS 网关(端口 9020) |
+| `mjava-benteler/` | 本特勒 EIAM 用户与组织同步 |
+| `mjava-harrison/` | Harrison Line 发票与业务单据处理 |
+| `mjava-guangming/` | 光明集团邮件 SSO 与应用权限同步 |
+
+## 项目边界
+
+- 可复用的第三方原子接口和基础能力放在 `mjava/`。
+- 钉钉角色与应用可见范围统一通过 `mjava` 的 `DDClient_Role` 调用,不再放入 `DDClient_Contacts` 或客户模块重复实现。
+- 个人验证和技术试验放在 `mjava-mcli/`,验证成熟后再迁入对应公共层。
+- 多客户共用的业务运行时放在 `mjava-pro/`;标准对外接口放在 `mjava-com/`。
+- 客户定制代码使用独立的 `mjava-{customer}/` Maven 模块、配置和可执行 jar,资料归入 `_mjava/2 one/<客户>/`。
+- 不属于本聚合工程的独立 Java 项目保留在后端根目录;当前 `akds` 位于 `/Users/malk/server/cur/akds`,通过 `后端/阿科德斯/程序` 快捷方式引用。
+
+## 配置约定
+
+- 每个可运行模块提交 `application.yml`,真实 `application-dev.yml`、`application-test.yml`、`application-prod.yml` 不入库。
+- 开发、生产配置示例使用 `application-{profile}.yml.example`;复制成对应 profile 文件后再启动。
+- 数据库密码、钉钉密钥、宜搭令牌及客户系统凭据统一通过环境变量注入,禁止写入源码和可提交配置。
 
 ## 构建
 
 ```bash
 mvn -pl mjava-{module} -am clean compile
-mvn -pl mjava-{module} -am clean package -Dmaven.test.skip=true
+mvn -pl mjava -DskipTests=false -Dmaven.test.skip=false test
+mvn clean package -DskipTests
 java -jar mjava-{module}/target/mjava-{module}.jar --spring.profiles.active=prod
 ```
 
 ## 文档与设计规范
 
-> **本仓库仅保留程序代码**。完整 README / CLAUDE / OpenSpec changes & specs / 子模块说明等开发设计规范统一在文档中心管理:
+> 本仓库保留源码、构建配置和源码级说明;客户事项、目录设计和开发规范统一在文档中心管理:
 >
-> `/Users/malk/Desktop/Tech/claude/后端/mjava-ai/`
+> `/Users/malk/Desktop/Tech/1. malk/后端/_mjava/`
 
-后端开发通用规范见 `/Users/malk/Desktop/Tech/claude/后端/CLAUDE.md`。
+后端开发通用规范见 `/Users/malk/Desktop/Tech/1. malk/后端/CLAUDE.md`。

+ 51 - 0
mjava-guangming/pom.xml

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>java-mcli</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>mjava-guangming</artifactId>
+    <description>光明集成:钉钉SSO免密登录德惠邮箱</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+        <!-- 核心模块 -->
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>${mjava.version}</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <fork>false</fork>
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 23 - 0
mjava-guangming/src/main/java/com/malk/guangming/Boot.java

@@ -0,0 +1,23 @@
+package com.malk.guangming;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import javax.persistence.EntityManager;
+
+@EnableJpaAuditing
+@SpringBootApplication(scanBasePackages = {"com.malk"})
+public class Boot {
+
+    public static void main(String... args) {
+        SpringApplication.run(Boot.class, args);
+    }
+
+    @Bean
+    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
+        return new JPAQueryFactory(entityManager);
+    }
+}

+ 32 - 0
mjava-guangming/src/main/java/com/malk/guangming/config/GuangmingConfig.java

@@ -0,0 +1,32 @@
+package com.malk.guangming.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "guangming")
+public class GuangmingConfig {
+
+    /** 钉钉配置 */
+    private Dingtalk dingtalk = new Dingtalk();
+
+    /** 邮箱SSO配置 */
+    private MailSso mailSso = new MailSso();
+
+    @Data
+    public static class Dingtalk {
+        private String appKey;
+        private String appSecret;
+        private String corpId;
+    }
+
+    @Data
+    public static class MailSso {
+        /** 德惠邮箱SSO地址 */
+        private String ssoUrl;
+        /** RSA公钥 */
+        private String publicKey;
+    }
+}

+ 38 - 0
mjava-guangming/src/main/java/com/malk/guangming/config/MappingTableNamingStrategy.java

@@ -0,0 +1,38 @@
+package com.malk.guangming.config;
+
+import org.hibernate.boot.model.naming.Identifier;
+import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy;
+import org.springframework.stereotype.Component;
+
+/**
+ * 映射表表名后缀策略:仅对 t_dingtalk_app_mapping 追加可配置后缀(测试环境 _test),
+ * 其余表沿用 Spring 默认 snake_case 命名,零副作用。
+ *
+ * <p>JPA {@code @Table(name=...)} 是编译期常量,无法用 ${} 占位;Spring Boot 会自动采用
+ * 上下文里 {@link SpringPhysicalNamingStrategy} 类型的 bean 替换默认策略,
+ * 继承它即保留所有列名/其他表的命名转换行为,只在此处对基表名追加后缀。
+ *
+ * <p>后缀来源:yaml {@code guangming.mapping.table-suffix}(dev/prod 不配置 = 空 = 走原表)。
+ */
+@Component
+public class MappingTableNamingStrategy extends SpringPhysicalNamingStrategy {
+
+    /** 受控基表名;仅此表参与后缀改名 */
+    private static final String BASE = "t_dingtalk_app_mapping";
+
+    @Value("${guangming.mapping.table-suffix:}")
+    private String tableSuffix;
+
+    @Override
+    public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment ctx) {
+        Identifier physical = super.toPhysicalTableName(name, ctx);
+        // fixme: 仅精确匹配基表名追加后缀,避免误伤其他表;后缀空则原样返回(dev/prod 行为不变)
+        if (physical != null && BASE.equals(physical.getText())
+                && tableSuffix != null && !tableSuffix.isEmpty()) {
+            return Identifier.toIdentifier(BASE + tableSuffix, physical.isQuoted());
+        }
+        return physical;
+    }
+}

+ 127 - 0
mjava-guangming/src/main/java/com/malk/guangming/controller/AppRoleSyncController.java

@@ -0,0 +1,127 @@
+package com.malk.guangming.controller;
+
+import com.malk.guangming.dto.AppPreviewDto;
+import com.malk.guangming.dto.AppSyncResultDto;
+import com.malk.guangming.repository.entity.primary.DingTalkAppMappingPo;
+import com.malk.guangming.service.AppRoleSyncService;
+import com.malk.server.common.McException;
+import com.malk.server.common.McR;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Slf4j
+@RestController
+@RequestMapping("/role-sync")
+public class AppRoleSyncController {
+
+    @Autowired
+    private AppRoleSyncService appRoleSyncService;
+
+    @PostMapping("/single")
+    public McR<AppSyncResultDto> single(@RequestParam String appKey) {
+        try {
+            return McR.success(appRoleSyncService.syncOne(appKey));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** 异步版 /single:读可见范围+建组后立即返回(status=expanding),部门展开放后台;大部门可见范围避免 HTTP 超时。轮询 /status 至 success 再 /users-sync */
+    @PostMapping("/single-async")
+    public McR<AppSyncResultDto> singleAsync(@RequestParam String appKey) {
+        try {
+            return McR.success(appRoleSyncService.syncOneAsync(appKey));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    @GetMapping("/preview")
+    public McR<AppPreviewDto> preview(@RequestParam String appKey) {
+        try {
+            return McR.success(appRoleSyncService.previewOne(appKey));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    @GetMapping("/status")
+    public McR<DingTalkAppMappingPo> status(@RequestParam String appKey) {
+        try {
+            return McR.success(appRoleSyncService.getStatus(appKey));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** Phase 2:对单应用绑定钉钉角色 + 全量推送人员 */
+    @PostMapping("/users-sync")
+    public McR<AppSyncResultDto> usersSync(@RequestParam String appKey) {
+        try {
+            return McR.success(appRoleSyncService.syncUsersForApp(appKey));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** Phase 2:全表批量(顺序对每条 mapping 跑 single + users-sync) */
+    @PostMapping("/all")
+    public McR<java.util.List<AppSyncResultDto>> all() {
+        return McR.success(appRoleSyncService.syncAll());
+    }
+
+    /** 从钉钉拉取自建应用列表 → 自动 INSERT 到映射表(已存在则跳过) */
+    @PostMapping("/import-apps")
+    public McR<java.util.Map> importApps() {
+        try {
+            return McR.success(appRoleSyncService.importApps());
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** 一站式初始化:钉钉新应用 → 落库 + 建角色 + 绑人员 + 反写应用可见范围 */
+    @PostMapping("/init")
+    public McR<AppSyncResultDto> init(@RequestParam String agentId,
+                                       @RequestParam(required = false) String appName,
+                                       @RequestParam(required = false) String appGroup) {
+        try {
+            return McR.success(appRoleSyncService.initApp(agentId, appName, appGroup));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** 第二步(独立):仅把应用可见范围写回为已建角色(不读可见范围/不建角色/不绑人) */
+    @PostMapping("/writeback")
+    public McR<java.util.Map> writeback(@RequestParam String agentId) {
+        try {
+            return McR.success(appRoleSyncService.writeBackOne(agentId));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    /** 增量更新角色成员(不动应用本身可见范围) */
+    @org.springframework.web.bind.annotation.PostMapping("/users-update")
+    public McR<java.util.Map> usersUpdate(@RequestParam String agentId,
+                                          @org.springframework.web.bind.annotation.RequestBody com.malk.guangming.dto.UsersUpdateDto body) {
+        try {
+            return McR.success(appRoleSyncService.updateUsers(agentId,
+                    body == null ? null : body.getAddUserIds(),
+                    body == null ? null : body.getDelUserIds()));
+        } catch (McException e) {
+            return wrap(e);
+        }
+    }
+
+    private static <T> McR<T> wrap(McException e) {
+        if ("404".equals(e.getCode())) return (McR<T>) McR.errorParam(e.getMessage());
+        return (McR<T>) McR.errorVendor(e.getMessage(), "dingtalk");
+    }
+}

+ 77 - 0
mjava-guangming/src/main/java/com/malk/guangming/controller/MailSsoController.java

@@ -0,0 +1,77 @@
+package com.malk.guangming.controller;
+
+import com.malk.guangming.config.GuangmingConfig;
+import com.malk.guangming.service.DingTalkAuthService;
+import com.malk.guangming.util.RSACrypt;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+@Slf4j
+@RestController
+public class MailSsoController {
+
+    @Autowired
+    private DingTalkAuthService dingTalkAuthService;
+
+    @Autowired
+    private GuangmingConfig config;
+
+    /**
+     * 钉钉SSO跳转德惠邮箱
+     * 前端通过JSAPI获取authCode后调用此接口,后端完成认证和加密后302跳转到邮箱
+     *
+     * @param authCode 钉钉免登授权码
+     */
+    @GetMapping("/sso/mail")
+    public void ssoMail(@RequestParam String authCode, HttpServletResponse response) throws IOException {
+        try {
+            // 1. 通过authCode获取用户userId
+            String userId = dingTalkAuthService.getUserId(authCode);
+            log.info("[SSO邮箱] userId: {}", userId);
+
+            // 2. 获取用户邮箱
+//            String email = dingTalkAuthService.getUserEmail(userId);
+//            String email = "leixiaochuan@brightfood.com";
+            String email = "linke@brightfood.com";
+
+            log.info("[SSO邮箱] email: {}", email);
+
+            // 3. 截取邮箱@前面的部分作为uid
+            String uid = email.contains("@") ? email.substring(0, email.indexOf("@")) : email;
+
+            // 4. RSA加密 + URL安全处理
+            String encryptedUid = RSACrypt.encrypt(uid, config.getMailSso().getPublicKey());
+            String safeUid = RSACrypt.toUrlSafe(encryptedUid);
+
+            // 5. 拼接SSO URL并跳转
+            String ssoUrl = config.getMailSso().getSsoUrl() + "&uid=" + safeUid;
+            log.info("[SSO邮箱] 跳转: {}", ssoUrl);
+            response.sendRedirect(ssoUrl);
+
+        } catch (Exception e) {
+            log.error("[SSO邮箱] 登录失败", e);
+            response.setContentType("text/html;charset=utf-8");
+            String errMsg = e.getMessage() != null ? e.getMessage().replace("'", "\\'") : "未知错误";
+            response.getWriter().write(
+                "<!DOCTYPE html><html><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1.0'>"
+                + "<script src='https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js'></script></head>"
+                + "<body><script>"
+                + "if(/DingTalk/i.test(navigator.userAgent)){"
+                +   "dd.device.notification.alert({title:'登录失败',message:'" + errMsg + "',buttonName:'返回',"
+                +     "onSuccess:function(){dd.biz.navigation.close();}});"
+                + "}else{"
+                +   "document.body.innerHTML='<div style=\"display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif\">"
+                +     "<div style=\"text-align:center\"><div style=\"width:48px;height:48px;margin:0 auto 16px;border-radius:50%;background:#fee;display:flex;justify-content:center;align-items:center;font-size:24px\">&#10060;</div>"
+                +     "<p style=\"font-size:15px;color:#666\">" + errMsg + "</p></div></div>';"
+                + "}"
+                + "</script></body></html>"
+            );
+        }
+    }
+}

+ 24 - 0
mjava-guangming/src/main/java/com/malk/guangming/dto/AppPreviewDto.java

@@ -0,0 +1,24 @@
+package com.malk.guangming.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Builder
+public class AppPreviewDto {
+
+    private String appName;
+
+    private List<Long> rawDeptIds;
+
+    private List<String> rawUserIds;
+
+    private Boolean isAllVisible;
+
+    private Boolean isHidden;
+
+    /** null when isAllVisible */
+    private List<String> expanded;
+}

+ 21 - 0
mjava-guangming/src/main/java/com/malk/guangming/dto/AppSyncResultDto.java

@@ -0,0 +1,21 @@
+package com.malk.guangming.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class AppSyncResultDto {
+
+    private String appName;
+
+    private String roleGroupId;
+
+    private Boolean isAllVisible;
+
+    /** null when isAllVisible */
+    private Integer expandedCount;
+
+    /** success / failed */
+    private String syncStatus;
+}

+ 13 - 0
mjava-guangming/src/main/java/com/malk/guangming/dto/UsersUpdateDto.java

@@ -0,0 +1,13 @@
+package com.malk.guangming.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class UsersUpdateDto {
+
+    private List<String> addUserIds;
+
+    private List<String> delUserIds;
+}

+ 24 - 0
mjava-guangming/src/main/java/com/malk/guangming/repository/dao/DingTalkAppMappingDao.java

@@ -0,0 +1,24 @@
+package com.malk.guangming.repository.dao;
+
+import com.malk.guangming.repository.entity.primary.DingTalkAppMappingPo;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+public interface DingTalkAppMappingDao extends JpaRepository<DingTalkAppMappingPo, Long> {
+
+    Optional<DingTalkAppMappingPo> findByAppKey(String appKey);
+
+    Optional<DingTalkAppMappingPo> findFirstByAgentId(String agentId);
+
+    Optional<DingTalkAppMappingPo> findFirstByRoleGroupIdIsNotNull();
+
+    @Modifying
+    @Transactional
+    @Query("update DingTalkAppMappingPo p set p.roleGroupId = :groupId where p.roleGroupId is null")
+    int updateAllRoleGroupId(@Param("groupId") String groupId);
+}

+ 82 - 0
mjava-guangming/src/main/java/com/malk/guangming/repository/entity/primary/DingTalkAppMappingPo.java

@@ -0,0 +1,82 @@
+package com.malk.guangming.repository.entity.primary;
+
+import com.malk.base.BasePo;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import java.util.Date;
+
+/**
+ * 钉钉开放平台应用 → 角色映射
+ * <p>
+ * BasePo 自动注入 id / createTime / updateTime,无需重复声明。
+ */
+@Entity
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Table(name = "t_dingtalk_app_mapping",
+        indexes = {
+                @Index(name = "uk_app_key", columnList = "appKey", unique = true),
+                @Index(name = "idx_agent_id", columnList = "agentId"),
+                @Index(name = "idx_app_name", columnList = "appName")
+        })
+public class DingTalkAppMappingPo extends BasePo {
+
+    /** 应用名称(即未来角色名) */
+    private String appName;
+
+    /** 应用分组,如 集团应用 / DHR人力助手 */
+    private String appGroup;
+
+    /** 钉钉 agentId */
+    private String agentId;
+
+    /** 钉钉应用 appKey */
+    private String appKey;
+
+    /** 保留字段,本期不用(corp 级凭据已够) */
+    private String appSecret;
+
+    /** 全表共享同一值,首次创建后回写所有行 */
+    private String roleGroupId;
+
+    /** 本期不创建,第二期回写 */
+    private String roleId;
+
+    /** 角色标签:正常 "分组 - 应用名",全员可见 "全员(无角色)",用于回写 axls 角色列 */
+    @Column(length = 128)
+    private String roleLabel;
+
+    /** 可见范围原始 deptIds JSON 数组 */
+    @Column(columnDefinition = "TEXT")
+    private String rawDeptIds;
+
+    /** 可见范围原始 userIds JSON 数组 */
+    @Column(columnDefinition = "TEXT")
+    private String rawUserIds;
+
+    /** 部门展开后去重 userId JSON 数组;全员可见时为空 */
+    @Column(columnDefinition = "MEDIUMTEXT")
+    private String expandedUsers;
+
+    /** 展开后人数;全员可见时为空 */
+    private Integer expandedCount;
+
+    /** 是否全员可见(deptIds 含 1) */
+    private Boolean isAllVisible;
+
+    /** 钉钉 isHidden 字段 */
+    private Boolean isHidden;
+
+    private Date lastSyncAt;
+
+    /** pending / success / failed */
+    private String syncStatus;
+
+    @Column(length = 512)
+    private String lastErrMsg;
+}

+ 712 - 0
mjava-guangming/src/main/java/com/malk/guangming/service/AppRoleSyncService.java

@@ -0,0 +1,712 @@
+package com.malk.guangming.service;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.guangming.config.GuangmingConfig;
+import com.malk.guangming.dto.AppPreviewDto;
+import com.malk.guangming.dto.AppSyncResultDto;
+import com.malk.guangming.repository.dao.DingTalkAppMappingDao;
+import com.malk.guangming.repository.entity.primary.DingTalkAppMappingPo;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import com.malk.service.dingtalk.DDClient_Role;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ForkJoinPool;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class AppRoleSyncService {
+
+    private static final String ROLE_GROUP_NAME = "开放平台应用权限";
+
+    /** 部门展开并行度:大部门可见范围同步展开时的并发钉钉调用数,避免顺序展开超 HTTP 超时 */
+    private static final int EXPAND_CONCURRENCY = 10;
+
+    @Autowired
+    private GuangmingConfig guangmingConfig;
+
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private DDClient_Contacts ddClient_contacts;
+
+    @Autowired
+    private DDClient_Role ddClient_role;
+
+    @Autowired
+    private DingTalkAppMappingDao mappingDao;
+
+    /** 自注入代理:@Async 自调用不生效,异步展开需经此代理触发 */
+    @Lazy
+    @Autowired
+    private AppRoleSyncService self;
+
+    /**
+     * 单应用同步:读取可见范围 → 部门展开 → 落库;全表首次同步时创建角色组
+     */
+    public AppSyncResultDto syncOne(String appKey) {
+        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
+                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
+
+        String token = getAccessToken();
+        VisibleScopeResolved scope;
+        try {
+            scope = resolveScope(token, mapping.getAgentId());
+        } catch (Exception e) {
+            log.error("[role-sync] resolveScope 失败, appKey={}", appKey, e);
+            mapping.setSyncStatus("failed");
+            mapping.setLastErrMsg(truncate("resolve scope failed: " + e.getMessage(), 500));
+            mapping.setLastSyncAt(new Date());
+            mappingDao.save(mapping);
+            throw e instanceof McException ? (McException) e : new McException("502", "dingtalk vendor error: " + e.getMessage());
+        }
+
+        // 1. 先把 raw + expanded 落库(visible_scopes 已成功)
+        mapping.setRawDeptIds(JSON.toJSONString(scope.deptIds));
+        mapping.setRawUserIds(JSON.toJSONString(scope.userIds));
+        mapping.setIsAllVisible(scope.isAllVisible);
+        mapping.setIsHidden(scope.isHidden);
+        if (scope.isAllVisible) {
+            mapping.setExpandedUsers(null);
+            mapping.setExpandedCount(null);
+        } else if (scope.expandOk) {
+            mapping.setExpandedUsers(JSON.toJSONString(scope.expanded));
+            mapping.setExpandedCount(scope.expanded.size());
+        }
+        mapping.setLastSyncAt(new Date());
+
+        // 2. 尝试创建/拿角色组(失败不丢 raw)
+        String roleGroupId = null;
+        String roleGroupErr = null;
+        try {
+            ensureRoleGroup(token);
+            roleGroupId = mappingDao.findFirstByRoleGroupIdIsNotNull()
+                    .map(DingTalkAppMappingPo::getRoleGroupId).orElse(null);
+            mapping.setRoleGroupId(roleGroupId);
+        } catch (Exception e) {
+            log.warn("[role-sync] 角色组创建失败(可能权限未开通 qyapi_manage_addresslist): {}", e.getMessage());
+            roleGroupErr = e.getMessage();
+        }
+
+        // 3. 统一 sync_status
+        String status;
+        StringBuilder err = new StringBuilder();
+        if (scope.expandErr != null) err.append("expand:").append(scope.expandErr).append(";");
+        if (roleGroupErr != null) err.append("role_group:").append(roleGroupErr).append(";");
+        if (scope.expandErr == null && roleGroupErr == null) {
+            status = "success";
+        } else if (roleGroupErr != null && scope.expandErr != null) {
+            status = "partial_no_expand_no_role_group";
+        } else if (roleGroupErr != null) {
+            status = "partial_no_role_group";
+        } else {
+            status = "partial_no_expand";
+        }
+        mapping.setSyncStatus(status);
+        mapping.setLastErrMsg(err.length() == 0 ? null : truncate(err.toString(), 500));
+        mappingDao.save(mapping);
+
+        return AppSyncResultDto.builder()
+                .appName(mapping.getAppName())
+                .roleGroupId(roleGroupId)
+                .isAllVisible(scope.isAllVisible)
+                .expandedCount(mapping.getExpandedCount())
+                .syncStatus(status)
+                .build();
+    }
+
+    /**
+     * 异步初始化(大部门可见范围优化):只读可见范围(快)+ 建角色组,状态置 expanding 后立即返回;
+     * 部门展开放后台 @Async 执行,完成转 success。避免大部门(差旅类数千部门)同步展开阻塞 HTTP 超时。
+     * 调用方:轮询 GET /status 至 success 后再 /users-sync 绑成员。
+     */
+    public AppSyncResultDto syncOneAsync(String appKey) {
+        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
+                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
+        String token = getAccessToken();
+        VisibleScopeResolved scope;
+        try {
+            scope = readScope(token, mapping.getAgentId());
+        } catch (Exception e) {
+            log.error("[role-sync] readScope 失败, appKey={}", appKey, e);
+            mapping.setSyncStatus("failed");
+            mapping.setLastErrMsg(truncate("read scope failed: " + e.getMessage(), 500));
+            mapping.setLastSyncAt(new Date());
+            mappingDao.save(mapping);
+            throw e instanceof McException ? (McException) e : new McException("502", "dingtalk vendor error: " + e.getMessage());
+        }
+        mapping.setRawDeptIds(JSON.toJSONString(scope.deptIds));
+        mapping.setRawUserIds(JSON.toJSONString(scope.userIds));
+        mapping.setIsAllVisible(scope.isAllVisible);
+        mapping.setIsHidden(scope.isHidden);
+        mapping.setLastSyncAt(new Date());
+
+        String roleGroupErr = null;
+        try {
+            ensureRoleGroup(token);
+            mapping.setRoleGroupId(mappingDao.findFirstByRoleGroupIdIsNotNull()
+                    .map(DingTalkAppMappingPo::getRoleGroupId).orElse(null));
+        } catch (Exception e) {
+            log.warn("[role-sync] 角色组创建失败: {}", e.getMessage());
+            roleGroupErr = e.getMessage();
+        }
+
+        String status;
+        if (scope.isAllVisible) {
+            mapping.setExpandedUsers(null);
+            mapping.setExpandedCount(null);
+            status = roleGroupErr == null ? "success" : "partial_no_role_group";
+        } else if (roleGroupErr != null) {
+            status = "partial_no_role_group";
+        } else {
+            status = "expanding";
+        }
+        mapping.setSyncStatus(status);
+        mapping.setLastErrMsg(roleGroupErr == null ? null : truncate("role_group:" + roleGroupErr, 500));
+        mappingDao.save(mapping);
+
+        if ("expanding".equals(status)) {
+            self.expandAsync(appKey);   // @Async 经自注入代理触发,立即返回,后台展开
+        }
+        return AppSyncResultDto.builder()
+                .appName(mapping.getAppName())
+                .roleGroupId(mapping.getRoleGroupId())
+                .isAllVisible(scope.isAllVisible)
+                .syncStatus(status)
+                .build();
+    }
+
+    /**
+     * 后台异步展开部门成员:基于 mapping 已落库的 raw_dept/user 并行展开,完成后落 expanded + 状态。
+     * 完成转 success;展开失败转 partial_no_expand(保留 raw)。重载 mapping 避免跨线程共享实体。
+     */
+    @Async
+    public void expandAsync(String appKey) {
+        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey).orElse(null);
+        if (mapping == null) return;
+        String token = getAccessToken();
+        try {
+            List<Long> depts = mapping.getRawDeptIds() == null ? Collections.emptyList()
+                    : JSON.parseArray(mapping.getRawDeptIds(), Long.class);
+            List<String> users = mapping.getRawUserIds() == null ? Collections.emptyList()
+                    : JSON.parseArray(mapping.getRawUserIds(), String.class);
+            Set<String> expanded = expandDeptUsersParallel(token, depts, users);
+            mapping.setExpandedUsers(JSON.toJSONString(expanded));
+            mapping.setExpandedCount(expanded.size());
+            mapping.setSyncStatus("success");
+            mapping.setLastErrMsg(null);
+            log.info("[role-sync] expandAsync 完成 appKey={} expanded={}", appKey, expanded.size());
+        } catch (Exception e) {
+            log.error("[role-sync] expandAsync 失败 appKey={}", appKey, e);
+            mapping.setSyncStatus("partial_no_expand");
+            mapping.setLastErrMsg(truncate("expand:" + e.getMessage(), 500));
+        }
+        mapping.setLastSyncAt(new Date());
+        mappingDao.save(mapping);
+    }
+
+    /**
+     * dryRun:不落库
+     */
+    public AppPreviewDto previewOne(String appKey) {
+        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
+                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
+
+        String token = getAccessToken();
+        VisibleScopeResolved scope = resolveScope(token, mapping.getAgentId());
+
+        return AppPreviewDto.builder()
+                .appName(mapping.getAppName())
+                .rawDeptIds(scope.deptIds)
+                .rawUserIds(scope.userIds)
+                .isAllVisible(scope.isAllVisible)
+                .isHidden(scope.isHidden)
+                .expanded(scope.isAllVisible ? null : new ArrayList<>(scope.expanded))
+                .build();
+    }
+
+    public DingTalkAppMappingPo getStatus(String appKey) {
+        return mappingDao.findByAppKey(appKey)
+                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
+    }
+
+    /**
+     * Phase 2:对单个应用绑定钉钉角色 + 全量推送人员。
+     * 前置:必须先跑过 syncOne(库内有 expanded_users 与 role_group_id)。
+     * 行为:
+     * - is_all_visible=1 跳过角色绑定(应用可见范围就是全员,无需 role)
+     * - role_id 为空时 roleCreate 拿到 roleId 回写
+     * - 全量 roleAddUsers(本期不做 diff 删除;V2 再加)
+     */
+    public AppSyncResultDto syncUsersForApp(String appKey) {
+        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
+                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
+
+        if (mapping.getRoleGroupId() == null) {
+            throw new McException("400", "role_group_id is null, run /single first: " + appKey);
+        }
+        if ("expanding".equals(mapping.getSyncStatus())) {
+            throw new McException("409", "部门展开进行中(异步),请待 /status 为 success 后再绑成员: " + appKey);
+        }
+
+        String token = getAccessToken();
+        try {
+            // 分支 A:全员可见 → 不建角色,改调钉钉 set_visible_scopes 把应用可见范围 addDeptIds=[1]
+            if (Boolean.TRUE.equals(mapping.getIsAllVisible())) {
+                ddClient_role.setMicroAppVisibleScopes(
+                        token, Long.parseLong(mapping.getAgentId()),
+                        java.util.Arrays.asList(1L), null, null, null, null, null);
+                mapping.setRoleLabel("全员(无角色)");
+                mapping.setSyncStatus("success_all_visible");
+                mapping.setLastErrMsg(null);
+                mapping.setLastSyncAt(new Date());
+                mappingDao.save(mapping);
+                return AppSyncResultDto.builder()
+                        .appName(mapping.getAppName())
+                        .roleGroupId(mapping.getRoleGroupId())
+                        .isAllVisible(Boolean.TRUE)
+                        .syncStatus("success_all_visible").build();
+            }
+
+            // 分支 B:非全员 → 建角色 + 加人,角色名 = "分组 - 应用名"
+            String roleName = buildRoleName(mapping);
+            if (mapping.getRoleId() == null) {
+                String roleId = ddClient_role.roleCreate(token, roleName, mapping.getRoleGroupId());
+                if (roleId == null) {
+                    throw new McException("502", "roleCreate returned null roleId");
+                }
+                mapping.setRoleId(roleId);
+            } else if (!roleName.equals(mapping.getRoleLabel())) {
+                // 已建但名称需要按新规则更新(如 DHR → "集团应用 - DHR")
+                ddClient_role.roleUpdate(token, mapping.getRoleId(), roleName);
+            }
+
+            List<String> users = JSON.parseArray(
+                    mapping.getExpandedUsers() == null ? "[]" : mapping.getExpandedUsers(),
+                    String.class);
+            ddClient_role.roleAddUsers(token, mapping.getRoleId(), users);
+
+            mapping.setRoleLabel(roleName);
+            mapping.setSyncStatus("success");
+            mapping.setLastErrMsg(null);
+            mapping.setLastSyncAt(new Date());
+            mappingDao.save(mapping);
+
+            return AppSyncResultDto.builder()
+                    .appName(mapping.getAppName())
+                    .roleGroupId(mapping.getRoleGroupId())
+                    .isAllVisible(Boolean.FALSE)
+                    .expandedCount(users.size())
+                    .syncStatus("success").build();
+        } catch (Exception e) {
+            log.error("[role-sync] syncUsersForApp 失败, appKey={}", appKey, e);
+            mapping.setSyncStatus("failed");
+            mapping.setLastErrMsg(truncate(e.getMessage(), 500));
+            mapping.setLastSyncAt(new Date());
+            mappingDao.save(mapping);
+            throw e instanceof McException ? (McException) e : new McException("502", "dingtalk vendor error: " + e.getMessage());
+        }
+    }
+
+    private static String buildRoleName(DingTalkAppMappingPo mapping) {
+        String group = mapping.getAppGroup();
+        if (group == null || group.isEmpty()) return mapping.getAppName();
+        return group + " - " + mapping.getAppName();
+    }
+
+    /**
+     * 从钉钉拉取自建应用列表,新增到 t_dingtalk_app_mapping;
+     * 已存在 appKey 跳过。返回新增数。
+     */
+    public Map importApps() {
+        String token = getAccessToken();
+        List<Map> apps = ddClient_role.listMicroApp(token);
+        int inserted = 0, skipped = 0;
+        // 钉钉 /microapp/list 不返回 appKey,以 agentId 作为唯一标识;app_key 字段写占位 "ag-{agentId}"
+        for (Map app : apps) {
+            Object agentIdObj = app.get("agentId");
+            String name = String.valueOf(app.get("name"));
+            if (agentIdObj == null) continue;
+            String agentId = String.valueOf(agentIdObj);
+            String appKeyPlaceholder = "ag-" + agentId;
+            if (mappingDao.findByAppKey(appKeyPlaceholder).isPresent() ||
+                mappingDao.findByAppKey(agentId).isPresent()) {
+                skipped++;
+                continue;
+            }
+            // 也排查真实 appKey 已存在的应用(如 DHR 手工录入)
+            boolean existsByAgent = mappingDao.findAll().stream()
+                    .anyMatch(p -> agentId.equals(p.getAgentId()));
+            if (existsByAgent) {
+                skipped++;
+                continue;
+            }
+            DingTalkAppMappingPo po = new DingTalkAppMappingPo();
+            po.setAppName(name);
+            po.setAgentId(agentId);
+            po.setAppKey(appKeyPlaceholder);
+            po.setSyncStatus("pending");
+            mappingDao.save(po);
+            inserted++;
+        }
+        Map ret = new java.util.HashMap();
+        ret.put("totalFromDingtalk", apps.size());
+        ret.put("inserted", inserted);
+        ret.put("skipped", skipped);
+        return ret;
+    }
+
+    /**
+     * 全量批量:对映射表里**所有**应用按顺序跑 syncOne → syncUsersForApp。
+     * 单条失败不中断其它,落 sync_status 即可。
+     */
+    /**
+     * 一站式初始化:落库 + 建角色 + 绑人员 + 反写应用可见范围
+     * 业务方主要入口,幂等(重复跑同 agentId,会重读可见范围并补齐数据)
+     */
+    public AppSyncResultDto initApp(String agentId, String appName, String appGroup) {
+        String token = getAccessToken();
+        // 0. 幂等守卫:已成功初始化的应用直接返回当前状态(可见范围已反写为角色或根部门,再次读 visible_scopes 会丢原始数据)
+        DingTalkAppMappingPo existing = mappingDao.findFirstByAgentId(agentId).orElse(null);
+        // fixme: skip_shortcut = OA办公组快捷应用(工作台快捷方式配置地址,默认全员可见),不动钉钉应用本身可见范围;
+        //        必须纳入幂等守卫,否则重跑会走全员分支误调 writeBackToRootDept 改可见范围
+        if (existing != null && existing.getLastSyncAt() != null &&
+            ("success".equals(existing.getSyncStatus()) || "success_all_visible".equals(existing.getSyncStatus())
+                    || "skip_shortcut".equals(existing.getSyncStatus()))) {
+            log.info("[role-sync] initApp 幂等返回 agentId={}, 已 {} 于 {}", agentId, existing.getSyncStatus(), existing.getLastSyncAt());
+            return AppSyncResultDto.builder()
+                    .appName(existing.getAppName())
+                    .roleGroupId(existing.getRoleGroupId())
+                    .isAllVisible(Boolean.TRUE.equals(existing.getIsAllVisible()))
+                    .expandedCount(existing.getExpandedCount())
+                    .syncStatus(existing.getSyncStatus()).build();
+        }
+
+        // 1. 查或建 mapping
+        DingTalkAppMappingPo mapping = existing;
+        if (mapping == null) {
+            String resolvedName = (appName != null && !appName.isEmpty()) ? appName : lookupAppNameFromDingtalk(token, agentId);
+            mapping = new DingTalkAppMappingPo();
+            mapping.setAppName(resolvedName);
+            mapping.setAppGroup(appGroup);
+            mapping.setAgentId(agentId);
+            mapping.setAppKey("ag-" + agentId);
+            mapping.setSyncStatus("pending");
+            mapping = mappingDao.save(mapping);
+        } else {
+            if (appName != null && !appName.isEmpty()) mapping.setAppName(appName);
+            if (appGroup != null) mapping.setAppGroup(appGroup);
+        }
+
+        // 2. 读可见范围 + 展开
+        VisibleScopeResolved scope = resolveScope(token, mapping.getAgentId());
+
+        // 3. 落 raw
+        mapping.setRawDeptIds(JSON.toJSONString(scope.deptIds));
+        mapping.setRawUserIds(JSON.toJSONString(scope.userIds));
+        mapping.setIsAllVisible(scope.isAllVisible);
+        mapping.setIsHidden(scope.isHidden);
+
+        // 4. 全局角色组
+        ensureRoleGroup(token);
+        String roleGroupId = mappingDao.findFirstByRoleGroupIdIsNotNull()
+                .map(DingTalkAppMappingPo::getRoleGroupId).orElse(null);
+        mapping.setRoleGroupId(roleGroupId);
+
+        long agentIdNum = Long.parseLong(mapping.getAgentId());
+        String status;
+        if (scope.isAllVisible) {
+            // 全员路径:反写应用可见范围 addDept=1 + 清非根 + 清 user
+            mapping.setExpandedUsers(null);
+            mapping.setExpandedCount(null);
+            mapping.setRoleLabel("全员(无角色)");
+            writeBackToRootDept(token, agentIdNum, scope.deptIds, scope.userIds);
+            status = "success_all_visible";
+        } else {
+            if (!scope.expandOk) {
+                throw new McException("502", "部门展开失败: " + scope.expandErr);
+            }
+            mapping.setExpandedUsers(JSON.toJSONString(scope.expanded));
+            mapping.setExpandedCount(scope.expanded.size());
+            String roleName = buildRoleName(mapping);
+            if (mapping.getRoleId() == null) {
+                String roleId = ddClient_role.roleCreate(token, roleName, roleGroupId);
+                if (roleId == null) throw new McException("502", "roleCreate returned null");
+                mapping.setRoleId(roleId);
+            }
+            mapping.setRoleLabel(roleName);
+            // 全量加人
+            ddClient_role.roleAddUsers(token, mapping.getRoleId(), scope.expanded);
+            // 反写:addRole + 清 dept/user
+            writeBackToRole(token, agentIdNum, mapping.getRoleId(), scope.deptIds, scope.userIds);
+            status = "success";
+        }
+        mapping.setSyncStatus(status);
+        mapping.setLastErrMsg(null);
+        mapping.setLastSyncAt(new Date());
+        mappingDao.save(mapping);
+
+        return AppSyncResultDto.builder()
+                .appName(mapping.getAppName())
+                .roleGroupId(roleGroupId)
+                .isAllVisible(scope.isAllVisible)
+                .expandedCount(mapping.getExpandedCount())
+                .syncStatus(status).build();
+    }
+
+    /**
+     * 增量更新角色成员(不动应用可见范围、不读可见范围)
+     */
+    public Map updateUsers(String agentId, List<String> addUserIds, List<String> delUserIds) {
+        DingTalkAppMappingPo mapping = mappingDao.findFirstByAgentId(agentId)
+                .orElseThrow(() -> new McException("404", "agentId not registered: " + agentId));
+        if (mapping.getRoleId() == null) {
+            throw new McException("400", "该应用未建角色(可能为全员可见),无法增量更新角色成员");
+        }
+        String token = getAccessToken();
+        int addedCount = 0, deletedCount = 0;
+        if (addUserIds != null && !addUserIds.isEmpty()) {
+            ddClient_role.roleAddUsers(token, mapping.getRoleId(), addUserIds);
+            addedCount = addUserIds.size();
+        }
+        if (delUserIds != null && !delUserIds.isEmpty()) {
+            ddClient_role.roleDeleteUsers(token, mapping.getRoleId(), delUserIds);
+            deletedCount = delUserIds.size();
+        }
+        // 更新 mapping.expanded_users
+        List<String> current = mapping.getExpandedUsers() == null
+                ? new ArrayList<>()
+                : JSON.parseArray(mapping.getExpandedUsers(), String.class);
+        LinkedHashSet<String> set = new LinkedHashSet<>(current);
+        if (addUserIds != null) set.addAll(addUserIds);
+        if (delUserIds != null) set.removeAll(delUserIds);
+        mapping.setExpandedUsers(JSON.toJSONString(set));
+        mapping.setExpandedCount(set.size());
+        mapping.setLastSyncAt(new Date());
+        mappingDao.save(mapping);
+
+        Map ret = new java.util.HashMap();
+        ret.put("agentId", agentId);
+        ret.put("roleId", mapping.getRoleId());
+        ret.put("addedCount", addedCount);
+        ret.put("deletedCount", deletedCount);
+        ret.put("totalCount", set.size());
+        return ret;
+    }
+
+    /**
+     * 第二步(独立):仅把应用可见范围写回为已建角色(addRoleIds + 清原 dept/user)。
+     * 不读可见范围、不展开、不建角色;要求该应用已建角色(role_id 非空)。
+     * 用 mapping 落库的 raw_dept_ids/raw_user_ids 作为待清的原可见范围。
+     */
+    public Map writeBackOne(String agentId) {
+        DingTalkAppMappingPo mapping = mappingDao.findFirstByAgentId(agentId)
+                .orElseThrow(() -> new McException("404", "agentId not registered: " + agentId));
+        if (mapping.getRoleId() == null) {
+            throw new McException("400", "该应用未建角色(可能为全员可见),无法写回可见范围为角色: " + agentId);
+        }
+        String token = getAccessToken();
+        List<Long> curDepts = mapping.getRawDeptIds() == null ? Collections.emptyList()
+                : JSON.parseArray(mapping.getRawDeptIds(), Long.class);
+        List<String> curUsers = mapping.getRawUserIds() == null ? Collections.emptyList()
+                : JSON.parseArray(mapping.getRawUserIds(), String.class);
+        writeBackToRole(token, Long.parseLong(mapping.getAgentId()), mapping.getRoleId(), curDepts, curUsers);
+        mapping.setLastSyncAt(new Date());
+        mappingDao.save(mapping);
+
+        Map ret = new java.util.HashMap();
+        ret.put("agentId", agentId);
+        ret.put("roleId", mapping.getRoleId());
+        ret.put("roleLabel", mapping.getRoleLabel());
+        ret.put("clearedDepts", curDepts.size());
+        ret.put("clearedUsers", curUsers.size());
+        return ret;
+    }
+
+    /** 反写为根部门:addDeptIds=[1] + 删非根 dept + 删全部 user(user 分 50/批) */
+    private void writeBackToRootDept(String token, long agentId, List<Long> curDepts, List<String> curUsers) {
+        List<Long> delDepts = curDepts == null ? Collections.emptyList()
+                : curDepts.stream().filter(d -> d != 1L).collect(Collectors.toList());
+        ddClient_role.setMicroAppVisibleScopes(token, agentId,
+                java.util.Arrays.asList(1L), null, null,
+                delDepts.isEmpty() ? null : delDepts, null, null);
+        delUsersBatched(token, agentId, curUsers);
+    }
+
+    /** 反写为角色:addRoleIds=[roleId] + 删 dept + 删 user(user 分 50/批) */
+    private void writeBackToRole(String token, long agentId, String roleId, List<Long> curDepts, List<String> curUsers) {
+        ddClient_role.setMicroAppVisibleScopes(token, agentId,
+                null, null, java.util.Arrays.asList(roleId),
+                curDepts == null || curDepts.isEmpty() ? null : curDepts, null, null);
+        delUsersBatched(token, agentId, curUsers);
+    }
+
+    /** 钉钉 set_visible_scopes 单次 delUserIds 上限约 50,分批 */
+    private void delUsersBatched(String token, long agentId, List<String> users) {
+        if (users == null || users.isEmpty()) return;
+        for (int i = 0; i < users.size(); i += 50) {
+            List<String> batch = users.subList(i, Math.min(i + 50, users.size()));
+            ddClient_role.setMicroAppVisibleScopes(token, agentId,
+                    null, null, null, null, batch, null);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private String lookupAppNameFromDingtalk(String token, String agentId) {
+        long aid = Long.parseLong(agentId);
+        List<Map> apps = ddClient_role.listMicroApp(token);
+        return apps.stream()
+                .filter(a -> a.get("agentId") != null && Long.parseLong(String.valueOf(a.get("agentId"))) == aid)
+                .map(a -> String.valueOf(a.get("name")))
+                .findFirst()
+                .orElseThrow(() -> new McException("404", "agentId 在钉钉应用列表中未找到: " + agentId));
+    }
+
+    public List<AppSyncResultDto> syncAll() {
+        List<DingTalkAppMappingPo> all = mappingDao.findAll();
+        List<AppSyncResultDto> results = new ArrayList<>(all.size());
+        for (DingTalkAppMappingPo po : all) {
+            try {
+                syncOne(po.getAppKey());
+                results.add(syncUsersForApp(po.getAppKey()));
+            } catch (Exception e) {
+                log.error("[role-sync] syncAll 单条失败, appKey={}", po.getAppKey(), e);
+                results.add(AppSyncResultDto.builder()
+                        .appName(po.getAppName())
+                        .syncStatus("failed")
+                        .build());
+            }
+        }
+        return results;
+    }
+
+    /// 内部工具 ///
+
+    private String getAccessToken() {
+        return ddClient.getAccessToken(
+                guangmingConfig.getDingtalk().getAppKey(),
+                guangmingConfig.getDingtalk().getAppSecret());
+    }
+
+    private VisibleScopeResolved resolveScope(String token, String agentIdStr) {
+        VisibleScopeResolved scope = readScope(token, agentIdStr);
+        if (!scope.isAllVisible) {
+            try {
+                scope.expanded = expandDeptUsersParallel(token, scope.deptIds, scope.userIds);
+                scope.expandOk = true;
+            } catch (Exception e) {
+                log.warn("[role-sync] 部门展开失败(可能权限未开通 qyapi_get_department_list),降级 raw-only: {}", e.getMessage());
+                scope.expandErr = e.getMessage();
+                scope.expandOk = false;
+            }
+        } else {
+            scope.expandOk = true;
+        }
+        return scope;
+    }
+
+    /** 只读可见范围(不展开):1 次 visible_scopes 调用,快;供异步展开路径先拿 raw 再后台展开 */
+    @SuppressWarnings("unchecked")
+    private VisibleScopeResolved readScope(String token, String agentIdStr) {
+        long agentId = Long.parseLong(agentIdStr);
+        Map rsp = ddClient_role.getMicroAppVisibleScopes(token, agentId);
+
+        List<Number> rawDept = rsp.get("deptVisibleScopes") instanceof List ? (List<Number>) rsp.get("deptVisibleScopes") : Collections.emptyList();
+        List<String> rawUser = rsp.get("userVisibleScopes") instanceof List ? (List<String>) rsp.get("userVisibleScopes") : Collections.emptyList();
+        Boolean isHidden = rsp.get("isHidden") instanceof Boolean ? (Boolean) rsp.get("isHidden") : Boolean.FALSE;
+
+        List<Long> deptIds = new ArrayList<>();
+        for (Number n : rawDept) deptIds.add(n.longValue());
+        List<String> userIds = new ArrayList<>(rawUser);
+
+        VisibleScopeResolved scope = new VisibleScopeResolved();
+        scope.deptIds = deptIds;
+        scope.userIds = userIds;
+        scope.isHidden = isHidden;
+        scope.isAllVisible = deptIds.contains(DDConf.TOP_DEPARTMENT);
+        return scope;
+    }
+
+    /**
+     * 并行展开部门成员:并行 BFS 收集子部门 + 并行取各部门成员。
+     * 替代 base 顺序递归 getDepartmentId_all + 逐部门 listDepartmentUserId,
+     * 降低大部门可见范围在同步 HTTP 内展开的耗时(差旅类 400+ 部门曾顺序 800+ 调用超时)。
+     */
+    private Set<String> expandDeptUsersParallel(String token, List<Long> deptIds, List<String> seedUserIds) throws Exception {
+        // 1. 并行 BFS 逐层收集子部门;visited(allDept)去重,避免重叠/嵌套部门重复展开
+        Set<Long> allDept = ConcurrentHashMap.newKeySet();
+        allDept.addAll(deptIds);
+        List<Long> frontier = new ArrayList<>(deptIds);
+        ForkJoinPool pool = new ForkJoinPool(EXPAND_CONCURRENCY);
+        try {
+            while (!frontier.isEmpty()) {
+                final List<Long> level = frontier;
+                List<Long> children = pool.submit(() ->
+                        level.parallelStream()
+                                .flatMap(d -> ddClient_contacts.listSubDepartmentId(token, d).stream())
+                                .collect(Collectors.toList())
+                ).get();
+                List<Long> next = new ArrayList<>();
+                for (Long c : children) {
+                    // fixme: allDept.add 返回 false = 已访问,跳过,防重叠部门导致的重复 BFS
+                    if (allDept.add(c)) next.add(c);
+                }
+                frontier = next;
+            }
+            // 2. 并行取各部门成员(seed = 可见范围里直接指定的 userIds)
+            List<Long> deptList = new ArrayList<>(allDept);
+            Set<String> deptUsers = pool.submit(() ->
+                    deptList.parallelStream()
+                            .flatMap(d -> ddClient_contacts.listDepartmentUserId(token, d).stream())
+                            .collect(Collectors.toSet())
+            ).get();
+            Set<String> users = new LinkedHashSet<>(seedUserIds);
+            users.addAll(deptUsers);
+            return users;
+        } finally {
+            pool.shutdown();
+        }
+    }
+
+    /**
+     * 全局角色组只创建一次。
+     * synchronized 保护并发同步同一个 appKey 时不重复创建。
+     */
+    private synchronized void ensureRoleGroup(String token) {
+        if (mappingDao.findFirstByRoleGroupIdIsNotNull().isPresent()) {
+            return;
+        }
+        String groupId = ddClient_role.roleGroupCreate(token, ROLE_GROUP_NAME);
+        if (groupId == null) {
+            throw new McException("502", "roleGroupCreate returned null groupId");
+        }
+        int updated = mappingDao.updateAllRoleGroupId(groupId);
+        log.info("[role-sync] 角色组创建成功 groupId={}, 回填行数={}", groupId, updated);
+    }
+
+    private static String truncate(String s, int max) {
+        if (s == null) return null;
+        return s.length() <= max ? s : s.substring(0, max);
+    }
+
+    /** 内部值对象 */
+    private static class VisibleScopeResolved {
+        List<Long> deptIds;
+        List<String> userIds;
+        Boolean isHidden;
+        boolean isAllVisible;
+        Set<String> expanded = Collections.emptySet();
+        boolean expandOk;
+        String expandErr;
+    }
+}

+ 62 - 0
mjava-guangming/src/main/java/com/malk/guangming/service/DingTalkAuthService.java

@@ -0,0 +1,62 @@
+package com.malk.guangming.service;
+
+import com.malk.guangming.config.GuangmingConfig;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Slf4j
+@Service
+public class DingTalkAuthService {
+
+    @Autowired
+    private GuangmingConfig config;
+
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private DDClient_Contacts ddClientContacts;
+
+    /**
+     * 通过免登授权码获取用户userId
+     *
+     * @param authCode 前端JSAPI获取的免登授权码
+     * @return 用户userId
+     */
+    public String getUserId(String authCode) {
+        String accessToken = ddClient.getAccessToken(
+                config.getDingtalk().getAppKey(),
+                config.getDingtalk().getAppSecret());
+        Map userInfo = ddClient.getUserInfoByCode(accessToken, authCode);
+        return userInfo.get("userid").toString();
+    }
+
+    /**
+     * 根据userId获取用户邮箱
+     *
+     * @param userId 钉钉用户ID
+     * @return 用户企业邮箱
+     */
+    public String getUserEmail(String userId) {
+        String accessToken = ddClient.getAccessToken(
+                config.getDingtalk().getAppKey(),
+                config.getDingtalk().getAppSecret());
+        Map userInfo = ddClientContacts.getUserInfoById(accessToken, userId);
+        // 优先取企业邮箱(org_email),其次取个人邮箱(email)
+        Object orgEmail = userInfo.get("org_email");
+        if (orgEmail != null && !orgEmail.toString().isEmpty()) {
+            return orgEmail.toString();
+        }
+        Object email = userInfo.get("email");
+        if (email != null && !email.toString().isEmpty()) {
+            return email.toString();
+        }
+        log.error("[钉钉] 用户 {} 未配置邮箱, 返回字段: {}", userId, userInfo.keySet());
+        throw new RuntimeException("该用户未配置企业邮箱");
+    }
+}

+ 44 - 0
mjava-guangming/src/main/java/com/malk/guangming/util/RSACrypt.java

@@ -0,0 +1,44 @@
+package com.malk.guangming.util;
+
+import javax.crypto.Cipher;
+import java.security.KeyFactory;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+
+/**
+ * RSA加密工具类 - 与德惠邮箱SSO文档保持一致
+ */
+public class RSACrypt {
+
+    private static final String ALGORITHM = "RSA";
+    private static final String CHARSET = "utf-8";
+
+    /**
+     * RSA公钥加密
+     *
+     * @param str       待加密字符串
+     * @param publicKey Base64编码的RSA公钥
+     * @return Base64编码的加密结果
+     */
+    public static String encrypt(String str, String publicKey) throws Exception {
+        byte[] decoded = Base64.getDecoder().decode(publicKey);
+        RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance(ALGORITHM)
+                .generatePublic(new X509EncodedKeySpec(decoded));
+        Cipher cipher = Cipher.getInstance(ALGORITHM);
+        cipher.init(Cipher.ENCRYPT_MODE, pubKey);
+        byte[] encrypted = cipher.doFinal(str.getBytes(CHARSET));
+        return Base64.getEncoder().encodeToString(encrypted);
+    }
+
+    /**
+     * Base64字符串URL安全处理
+     * + → *,/ → -,= → .
+     *
+     * @param base64String 原始Base64字符串
+     * @return URL安全的字符串
+     */
+    public static String toUrlSafe(String base64String) {
+        return base64String.replace('+', '*').replace('/', '-').replace('=', '.');
+    }
+}

+ 17 - 0
mjava-guangming/src/main/resources/application-dev.yml.example

@@ -0,0 +1,17 @@
+# 复制为 application-dev.yml 后按开发环境填写;真实文件已被 .gitignore 排除。
+spring:
+  datasource:
+    hikari:
+      connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    username: ${DB_USERNAME:root}
+    password: ${DB_PASSWORD:}
+    url: ${DB_URL:jdbc:mysql://127.0.0.1:3306/mjava_guangming?useUnicode=true&characterEncoding=utf8}
+  jpa:
+    database: MYSQL
+    database-platform: org.hibernate.dialect.MySQL57Dialect
+    show-sql: false
+
+guangming:
+  mapping:
+    table-suffix: ${GUANGMING_MAPPING_TABLE_SUFFIX:_dev}

+ 17 - 0
mjava-guangming/src/main/resources/application-prod.yml.example

@@ -0,0 +1,17 @@
+# 复制为 application-prod.yml 后部署;所有敏感值均由环境变量注入。
+spring:
+  datasource:
+    hikari:
+      connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    username: ${DB_USERNAME}
+    password: ${DB_PASSWORD}
+    url: ${DB_URL}
+  jpa:
+    database: MYSQL
+    database-platform: org.hibernate.dialect.MySQL57Dialect
+    show-sql: false
+
+guangming:
+  mapping:
+    table-suffix: ${GUANGMING_MAPPING_TABLE_SUFFIX:}

+ 27 - 0
mjava-guangming/src/main/resources/application.yml

@@ -0,0 +1,27 @@
+server:
+  port: ${SERVER_PORT:9040}
+  servlet:
+    context-path: /api/guangming
+
+spel:
+  scheduling: false
+  multiSource: false
+
+spring:
+  profiles:
+    active: ${SPRING_PROFILES_ACTIVE:dev}
+  jpa:
+    hibernate:
+      ddl-auto: none
+    open-in-view: false
+
+guangming:
+  mapping:
+    table-suffix: ${GUANGMING_MAPPING_TABLE_SUFFIX:}
+  dingtalk:
+    appKey: ${GUANGMING_DINGTALK_APP_KEY:}
+    appSecret: ${GUANGMING_DINGTALK_APP_SECRET:}
+    corpId: ${GUANGMING_DINGTALK_CORP_ID:}
+  mailSso:
+    ssoUrl: ${GUANGMING_MAIL_SSO_URL:}
+    publicKey: ${GUANGMING_MAIL_SSO_PUBLIC_KEY:}

+ 138 - 0
mjava-guangming/src/main/resources/static/sso.html

@@ -0,0 +1,138 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>邮箱登录</title>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body {
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            height: 100vh;
+            background: #f5f5f5;
+            font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
+        }
+        .container {
+            text-align: center;
+            padding: 40px;
+        }
+        .tip-icon {
+            width: 48px;
+            height: 48px;
+            margin: 0 auto 16px;
+            border-radius: 50%;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            font-size: 24px;
+        }
+        .tip-icon.warn { background: #fff3e0; }
+        .spinner {
+            width: 36px;
+            height: 36px;
+            margin: 0 auto 16px;
+            border: 3px solid #e0e0e0;
+            border-top-color: #1890ff;
+            border-radius: 50%;
+            animation: spin 0.8s linear infinite;
+        }
+        @keyframes spin {
+            to { transform: rotate(360deg); }
+        }
+        .tip-text {
+            font-size: 15px;
+            color: #666;
+            line-height: 1.6;
+        }
+    </style>
+</head>
+<body>
+    <!-- 页面内loading(兜底) -->
+    <div class="container" id="loading" style="display:none;">
+        <div class="spinner"></div>
+        <p class="tip-text" id="loadingText">正在登录邮箱...</p>
+    </div>
+
+    <!-- 非钉钉环境提示 -->
+    <div class="container" id="tipContainer" style="display:none;">
+        <div class="tip-icon warn">&#9888;</div>
+        <p class="tip-text">请在钉钉内打开此页面<br>浏览器不支持自动登录</p>
+    </div>
+
+    <script src="https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js"></script>
+    <script>
+        var CORP_ID = 'dingc54682076c69f0f2bc961a6cb783455b';
+
+        function isDingTalk() {
+            return /DingTalk/i.test(navigator.userAgent);
+        }
+
+        // 钉钉原生loading
+        function showLoading(text) {
+            dd.device.notification.showPreloader({ text: text || '正在跳转邮箱...' });
+        }
+
+        function hideLoading() {
+            dd.device.notification.hidePreloader();
+        }
+
+        // 钉钉原生toast提示
+        function showToast(text, icon) {
+            dd.device.notification.toast({ icon: icon || '', text: text, duration: 3 });
+        }
+
+        // 钉钉原生alert弹窗(用于错误提示)
+        function showAlert(title, message) {
+            hideLoading();
+            dd.device.notification.alert({
+                title: title || '提示',
+                message: message,
+                buttonName: '我知道了'
+            });
+        }
+
+        function updateLoading(text) {
+            document.getElementById('loadingText').textContent = text;
+        }
+
+        if (!isDingTalk()) {
+            document.getElementById('tipContainer').style.display = 'block';
+        } else {
+            // 页面内loading + 钉钉原生loading
+            document.getElementById('loading').style.display = 'block';
+            showLoading('正在登录邮箱...');
+
+            try {
+                var result = dd.runtime.permission.requestAuthCode({
+                    corpId: CORP_ID,
+                    onSuccess: function(res) {
+                        updateLoading('正在跳转邮箱系统...');
+                        showLoading('正在跳转邮箱系统...');
+                        window.location.href = '/api/gm/sso/mail?authCode=' + res.code;
+                    },
+                    onFail: function(err) {
+                        showAlert('授权失败', err.errorMessage || JSON.stringify(err));
+                    }
+                });
+                // 新版JSAPI返回Promise
+                if (result && typeof result.then === 'function') {
+                    result.then(function(res) {
+                        updateLoading('正在跳转邮箱系统...');
+                        showLoading('正在跳转邮箱系统...');
+                        var code = res.code || (res.result && res.result.code);
+                        if (code) {
+                            window.location.href = '/api/gm/sso/mail?authCode=' + code;
+                        }
+                    }).catch(function(err) {
+                        showAlert('授权失败', err.errorMessage || JSON.stringify(err));
+                    });
+                }
+            } catch(e) {
+                showAlert('系统异常', e.message);
+            }
+        }
+    </script>
+</body>
+</html>

+ 37 - 0
mjava-guangming/src/test/java/com/malk/guangming/config/GuangmingConfigTest.java

@@ -0,0 +1,37 @@
+package com.malk.guangming.config;
+
+import org.junit.Test;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * 光明环境配置绑定测试。
+ */
+public class GuangmingConfigTest {
+
+    @Test
+    public void bind_nestedCredentials_returnsConfiguredValues() {
+        Map<String, Object> properties = new HashMap<>();
+        properties.put("guangming.dingtalk.app-key", "app-key-test");
+        properties.put("guangming.dingtalk.app-secret", "app-secret-test");
+        properties.put("guangming.dingtalk.corp-id", "corp-test");
+        properties.put("guangming.mail-sso.sso-url", "https://mail.example.test/sso");
+        properties.put("guangming.mail-sso.public-key", "public-key-test");
+
+        GuangmingConfig config = new Binder(new MapConfigurationPropertySource(properties))
+                .bind("guangming", Bindable.of(GuangmingConfig.class))
+                .get();
+
+        assertEquals("app-key-test", config.getDingtalk().getAppKey());
+        assertEquals("app-secret-test", config.getDingtalk().getAppSecret());
+        assertEquals("corp-test", config.getDingtalk().getCorpId());
+        assertEquals("https://mail.example.test/sso", config.getMailSso().getSsoUrl());
+        assertEquals("public-key-test", config.getMailSso().getPublicKey());
+    }
+}

+ 54 - 0
mjava-harrison/pom.xml

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>java-mcli</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>mjava-harrison</artifactId>
+    <description>Harrison Line 发票与业务单据处理</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+        <!-- 核心模块-->
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>${mjava.version}</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <!-- 如果没有该配置,devtools不会生效: 打包时关闭 -->
+                    <fork>false</fork>
+                    <!-- 避免中文乱码 -->
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <!-- 允许生成可运行jar -->
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 32 - 0
mjava-harrison/src/main/java/com/malk/harrison/Boot.java

@@ -0,0 +1,32 @@
+package com.malk.harrison;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import javax.persistence.EntityManager;
+
+/**
+ * corp项目: 扫描公共模块
+ * -
+ * 若是无需数据库模块, 配置无效地址也可启动, 引入mjava不支持直接 @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) 配置
+ * 需要配置 jpa.hibernate.ddl-auto 为 none. 标识对表没有任何操作. 若不设置为 none, flyway.enabled 配置会无效, 在没有数库连接情况下程序无法启动
+ */
+@EnableJpaAuditing
+@SpringBootApplication(scanBasePackages = {"com.malk"})
+public class Boot {
+
+    public static void main(String... args) {
+        SpringApplication.run(Boot.class, args);
+    }
+
+    /**
+     * 让Spring管理JPAQueryFactory [不使用Qualifier详见mjava-Boot]
+     */
+    @Bean
+    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
+        return new JPAQueryFactory(entityManager);
+    }
+}

+ 31 - 0
mjava-harrison/src/main/java/com/malk/harrison/config/HarrisonConfig.java

@@ -0,0 +1,31 @@
+package com.malk.harrison.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Harrison 客户模块配置。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "harrison")
+public class HarrisonConfig {
+
+    private Xiangyu xiangyu = new Xiangyu();
+    private Yida yida = new Yida();
+
+    @Data
+    public static class Xiangyu {
+        private String baseUrl;
+        private String system;
+        private String secure;
+        private String apiKey;
+    }
+
+    @Data
+    public static class Yida {
+        private String appType;
+        private String systemToken;
+    }
+}

+ 22 - 0
mjava-harrison/src/main/java/com/malk/harrison/controller/DDController.java

@@ -0,0 +1,22 @@
+package com.malk.harrison.controller;
+
+import com.malk.controller.DDCallbackController;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 钉钉事件回调 3_1
+ * -
+ * [子项目直接继承即可有调用, 无需实现]
+ * -
+ * 注解 @RequestMapping 路径不能重复 [主子项目属同一个项目];
+ * 获取项目回调请求地址, https://mc.cloudpure.cn/frp/xxx/dd/callback [调试代理: frp + nginx]
+ */
+@Slf4j
+@RestController
+@RequestMapping("/dd")
+public class DDController extends DDCallbackController {
+
+
+}

+ 115 - 0
mjava-harrison/src/main/java/com/malk/harrison/controller/HLController.java

@@ -0,0 +1,115 @@
+package com.malk.harrison.controller;
+
+import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.ExcelWriter;
+import com.alibaba.excel.write.metadata.WriteSheet;
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.common.McR;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.harrison.server.XYR;
+import com.malk.utils.*;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.InputStream;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 错误抛出与拦截详见 CatchException
+ */
+@Slf4j
+@RestController
+@RequestMapping("/harrison")
+public class HLController {
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private DDConf ddConf;
+
+    // prd: 物流类型打印模板 业务类型 中文->英文 对照
+    private static final Map<String, String> PART_EN = new HashMap<>();
+
+    static {
+        PART_EN.put("内陆运输", "TRANSPORTATION");
+        PART_EN.put("THC", "TERMINAL HANDLING CHARGES");
+        PART_EN.put("港口货代", "TNPA CARGO DUES");
+        PART_EN.put("海运", "OCEAN FREIGHT");
+        PART_EN.put("检测", "SAMPLING AND ANALYSIS");
+        PART_EN.put("单证", "DOCUMENTATION FEE");
+        PART_EN.put("保险", "OCEAN INSURANCE");
+    }
+
+    @PostMapping("/invoice")
+    McR HL_invoice(@RequestBody Map data, HttpServletResponse response, HttpServletRequest request) {
+
+//        data.putAll(UtilMap.map("type, abbr, formId", "销售发票, FLOURISH, 4070f44f-72da-4beb-b4b4-64d65d70c271"));
+        log.info("invoice, {}", data);
+
+        McException.assertParamException_Null(data, "formId, abbr, type");
+
+        String formId = UtilMap.getString(data, "formId");
+        Map formData = ydClient.queryData(YDParam.builder()
+                .formInstId(formId)
+                .build(), YDConf.FORM_QUERY.retrieve_id).getFormData();
+
+        /// 字段: 销售方, 购买方, 发票号, 合同号, 付款条款, 船名, 提单号, 原厂地(国家), 装货港, 卸货港, 包装方式, 贸易方式, 提单日期, 品名
+        Map printData = UtilMap.map("seller, buyer, invoice, contract, payment, vessel, lading, origin, ladP, disP, packing, trade, bDate, commodity",
+                "selectField_mgvg350c, selectField_mgvh3io0, textField_l6q5sysr, selectField_mkdpx3e7, selectField_l5rljms7, selectField_migsdo11, textField_migsdo16, textField_migsdo17, selectField_mkdpx3e8, selectField_mkdpx3e9, textareaField_migsdo1a, textField_migsdo1b, dateField_migsdo1e, selectField_l6mztuvx",
+                formData);
+        printData.put("date", UtilDateTime.formatDate(new Date(UtilMap.getLong(formData, "dateField_mgvqvr4l")))); // 开票日期
+        String title = UtilMap.getString(formData, "selectField_mgvqvr4j").equals("最终发票") ? "FINAL COMMERCIAL INVOICE" : "PROVISIONAL COMMERCIAL INVOICE";
+        printData.put("title", title); // 发票类型
+        String[] sAddrs = UtilMap.getString(formData, "textareaField_migsdo0w").split("\\|");
+        for (int i = 0; i < sAddrs.length; i++) {
+            printData.put("sAddr" + i, sAddrs[i]); // 销售方-地址
+        }
+        String[] bAddrs = UtilMap.getString(formData, "textareaField_migsdo0x").split("\\|");
+        for (int i = 0; i < bAddrs.length; i++) {
+            printData.put("bAddr" + i, bAddrs[i]); // 购买方-地址
+        }
+        // prd 0605 贸易打印模板修改,页面可从明细带出如船、装港、卸港,从第一条带出
+        printData.put("wet", UtilMap.getFloat(formData, "numberField_mpzjdkj0")); // 湿吨
+        printData.put("hsl", UtilMap.getFloat(formData, "numberField_mq6r7ntr")); // 含水量
+        printData.put("dry", UtilMap.getFloat(formData, "numberField_mpzjdkj1")); // 干吨
+        printData.put("gap", UtilMap.getFloat(formData, "numberField_mpzjdkj0") - UtilMap.getFloat(formData, "numberField_mpzjdkj1")); // 差值
+        printData.put("unit", UtilMap.getFloat(formData, "numberField_mpzo2g8o")); // 单价-美金
+        printData.put("qmt", UtilMap.getFloat(formData, "numberField_mq6sdnqp")); // 计算吨数
+
+        List<Map> details =  UtilMap.getList(formData, "tableField_mgvqvr53");
+        List<Map> dataList = details.stream().map(item-> {
+            // 字段: 业务-业务类型, 吨数-结算吨数, 单价-发票金额(美金), 总价-发票合计(美金), 汇率
+            Map row = UtilMap.map("part, num, price, amount, rate",
+                    "selectField_mgugk66v, numberField_mgvqvr58, numberField_mpzo2g8p, numberField_l49o1fgx, numberField_mgugk673",
+                    item);
+            String part = UtilMap.getString(item, "selectField_mgugk66v");
+            row.put("part", PART_EN.getOrDefault(part, part)); // prd: 物流类型模板展示英文业务类型, 命中映射才替换
+            row.put("quantity", UtilMap.getFloat(item, "numberField_mgvqvr58") + "MT");
+            row.put("exchange", UtilNumber.formatPrecisionString(UtilMap.getFloat(item, "numberField_l5ryl6qe") / UtilMap.getFloat(item, "numberField_mgugk673")));
+            row.put("empty", ""); // ppExt: 添加了子表, 若模板未用到会异常, 添加标注
+            return row;
+        }).collect(Collectors.toList());
+        printData.put("oTotal", UtilMap.getDouble(formData, "numberField_mgvvsif6")); // 发票金额合计-美金 // fixme: 百万级金额, float32 精度不足会显示为 2816951.20, 用 Double
+        log.info("printData, {}", printData);
+
+        String type = UtilMap.getString(data, "type"); // 模板类型
+        String abbr = UtilMap.getString(data, "abbr"); // 公司简称
+        String fileName = abbr + " " + title;
+        UtilExcel.exportMapAndListByTemplate(response, printData, dataList, Map.class, fileName, abbr + " " + type + ".xlsx");
+        return  McR.success();
+    }
+
+}

+ 142 - 0
mjava-harrison/src/main/java/com/malk/harrison/controller/SDSController.java

@@ -0,0 +1,142 @@
+package com.malk.harrison.controller;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.malk.harrison.config.HarrisonConfig;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.utils.UtilServlet;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.*;
+
+/**
+ * 错误抛出与拦截详见 CatchException
+ */
+@Slf4j
+@RestController
+@RequestMapping("/suodisi")
+public class SDSController {
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private DDConf ddConf;
+
+    @Autowired
+    private HarrisonConfig harrisonConfig;
+
+    /**
+     * 动态审批人
+     */
+    @PostMapping("sds/test")
+    List<String> sdsTest(HttpServletRequest request) {
+        Map param = UtilServlet.getParamMap(request);
+
+        if (true) {
+            return Arrays.asList(ddConf.getOperator());
+        }
+
+        McException.assertParamException_Null(param, "compId", "BusinessType");
+        Map condition = new HashMap();
+
+        String compId = param.get("compId").toString();
+        //数据条件参数
+        condition.put("textField_lisbeacj", param.get("BusinessType"));
+        condition.put("selectField_lisbeack", param.get("City"));
+        condition.put("selectField_lisbeacm", param.get("CompanyCode"));
+        //宜搭参数赋值
+        YDParam ydParam = YDParam.builder()
+                .formUuid("FORM-PR8667D1QWKB5AYJEKDJ18IKE6IE3JUNDBSIL2")
+                .searchFieldJson(JSON.toJSONString(condition))
+                .build();
+        List<Map> list = (List<Map>) ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_search_form).getData();
+
+        return emailList(list, ydParam, compId);
+    }
+
+    //获取人员ID的公用方法
+    List<String> emailList(List<Map> list, YDParam ydParam, String ProcessCoding) {
+        List<String> userIds = new ArrayList<>();
+        for (Map row : list) {
+            Map mailCondition = new HashMap();
+            Map formData = (Map) row.get("formData");
+            //String mail1 = String.valueOf(formData.getDefault("textField_lisbeacp"));
+            String mail1 = String.valueOf(formData.get(ProcessCoding));
+            for (String mail : mail1.split(";#")) {
+                if (mail.contains("@")) {
+                    mailCondition.put("textField_lg22sfep", mail);
+                    ydParam.setFormUuid("FORM-MFA66S91RAO8YAB1CUTCED77NK9133TR5XQEL8");
+                    ydParam.setSearchFieldJson(JSON.toJSONString(mailCondition));
+                    //调用宜搭接口
+                    List<Map> users = (List<Map>) ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_search_form).getData();
+                    if (users.size() > 0) {
+                        Map userData = (Map) users.get(0).get("formData");
+                        List<String> userId = (List<String>) userData.get("employeeField_lisbbxca_id");
+                        userIds.addAll(userId);
+                    }
+                }
+            }
+        }
+        return userIds;
+    }
+
+    /**
+     * 回写招聘子表
+     */
+    @PostMapping("sds/update")
+    Map sdsUpdate(HttpServletRequest request) {
+
+        // 查询招聘申请
+        Map param = UtilServlet.getParamMap(request);
+        McException.assertParamException_Null(param, "formInstId");
+        YDParam ydParam = YDParam.builder()
+                .appType(harrisonConfig.getYida().getAppType())
+                .systemToken(harrisonConfig.getYida().getSystemToken())
+                .formInstId(String.valueOf(param.get("formInstId")))
+                .build();
+        Map rsp = (Map) ydClient.operateData(ydParam, YDConf.FORM_OPERATION.update);
+        Map formData = (Map) rsp.get("formData");
+        log.info("索迪斯回写子表, {}, {}", param, formData);
+
+        // 组装分发子表数据
+        Map detail = new HashMap();
+        detail.put("associationFormField_lf7pkag4", JSON.parse(String.valueOf(formData.get("associationFormField_lg54gbt9_id")))); // 职位组
+        detail.put("textField_lf7pkag5", formData.get("textField_lg54gbta")); // 新员工姓名
+        detail.put("dateField_lf7pkag6", formData.get("dateField_lg54gbtf")); // 入职日期
+
+        // todo: 成员, 附件, 关联表单
+        List<Map> association = JSON.parseArray(String.valueOf(JSON.parse(String.valueOf(formData.get("associationFormField_lg0gmbi8_id")))), Map.class);
+        String formInstId = String.valueOf(association.get(0).get("instanceId"));
+        ydParam.setFormInstId(formInstId);
+        Map rspSrc = (Map) ydClient.operateData(ydParam, YDConf.FORM_OPERATION.update);
+        Map formDataSrc = (Map) rspSrc.get("formData");
+        List<Map> list = new ArrayList<>();
+        if (ObjectUtil.isNotNull(formDataSrc.get("tableField_lf7pkag3"))) {
+            list = (List<Map>) formDataSrc.get("tableField_lf7pkag3");
+        }
+
+        // 更新流程数据
+        Map form = new HashMap();
+        list.add(detail);
+        form.put("tableField_lf7pkag3", list);
+        form.put("numberField_lfj9d501", 2);
+        ydParam.setUpdateFormDataJson(JSON.toJSONString(form));
+        ydClient.operateData(ydParam, YDConf.FORM_OPERATION.update);
+
+        log.info("更新, {}, {}", formInstId, form);
+
+        Map r = new HashMap();
+        r.put("success", true);
+        return r;
+    }
+}

+ 247 - 0
mjava-harrison/src/main/java/com/malk/harrison/controller/XYController.java

@@ -0,0 +1,247 @@
+package com.malk.harrison.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.malk.harrison.config.HarrisonConfig;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.common.McR;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.harrison.entity.XyTodo;
+import com.malk.harrison.server.XYR;
+import com.malk.utils.UtilDateTime;
+import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.logging.log4j.util.Strings;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 错误抛出与拦截详见 CatchException
+ */
+@Slf4j
+@RestController
+@RequestMapping("/xy")
+public class XYController {
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private YDConf ydConf;
+
+    @Autowired
+    private HarrisonConfig harrisonConfig;
+
+    private static Map<Integer, String> DD_category = new HashMap<>();
+
+    static {
+        DD_category.put(0, "钉钉,宜搭");
+        DD_category.put(1, "钉钉,待办");
+    }
+
+    private String getXyUrl(String path) {
+        return harrisonConfig.getXiangyu().getBaseUrl() + path;
+    }
+
+    /**
+     * 创建象屿统一待办
+     * - prd
+     * 1. 待办创建, 更新均需要传递全部参数 (目前仅会校验审批时间不能小于历史数据)
+     * 2. 接收人存在多人时, 统一待办会自动拆分为多个 (待办中心以 业务单据号 + 用户域账号)
+     */
+    private void syncXyTodo(XyTodo todo) {
+        try {
+            Map body = todo.toMap();
+            XYR r = (XYR) UtilHttp.doPost(getXyUrl("/push"),
+                    UtilMap.map("deipaaskeyauth", harrisonConfig.getXiangyu().getApiKey()),
+                    body, XYR.class);
+
+            log.info("创建统一待办, {}", r);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * 删除象屿统一待办
+     */
+    private void deleteXyTodo(String busNo) {
+        try {
+            XYR r = (XYR) UtilHttp.doPost(getXyUrl("/delete"), null, UtilMap.map("busNo",busNo), XYR.class);
+
+            log.info("删除统一待办, {}", r);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * 宜搭待办
+     */
+    @PostMapping("/sync")
+    McR sync(@RequestBody Map map) {
+        String formInstId = UtilMap.getString(map, "formInstId");//审批实例id
+
+        String busNo = UtilMap.getString(map, "busNo");//唯一流水号
+
+        String boName = UtilMap.getString(map,"boName");//单据名称
+
+        String pcUrl = "https://xiangyu.aliwork.com/"+ydConf.getAppType()+"/processDetail?procInsId="+formInstId;
+        String appUrl = "https://xiangyu.aliwork.com/"+ydConf.getAppType()+"/processDetail?procInsId="+formInstId;
+
+        //查询宜搭审批记录, 完成回调, 完成更新
+        Map<String,Object> param = new HashMap<>();
+        param.put("appType",ydConf.getAppType());
+        param.put("systemToken",ydConf.getSystemToken());
+        param.put("userId","yida_pub_account");
+        param.put("processInstanceId",formInstId);
+
+        Map<String,Object> result = (Map<String,Object>) JSONObject.parse(UtilHttp.doGet("https://api.dingtalk.com/v1.0/yida/processes/operationRecords", ddClient.initTokenHeader(), param));
+
+        List<Map<String,Object>> operationRecords = UtilMap.getList(result,"result");
+
+        XyTodo xyTodo = new XyTodo(
+                harrisonConfig.getXiangyu().getSystem(),
+                harrisonConfig.getXiangyu().getSecure(),
+                DD_category.get(0));
+
+        xyTodo.setBusNo(busNo);//唯一流水号
+        xyTodo.setCreateDateTime(UtilDateTime.formatDateTime(new Date()));//创建时间
+        xyTodo.setPcUrl(pcUrl);
+        xyTodo.setAppUrl(appUrl);
+        xyTodo.setOptType(2);//操作类型:1、待办;2、已办;3、待阅
+        xyTodo.setCreateDateTime(UtilDateTime.formatDateTime(new Date()));//创建时间
+        xyTodo.setReceiveDateTime(UtilDateTime.formatDateTime(new Date()));//接收时间
+        xyTodo.setApproveDateTime(UtilDateTime.formatDateTime(new Date()));//审批时间
+
+        boolean isFirst = true;
+
+        //创建人、接收人账号应取象屿平台账号,若找不到账号则固定人员:陈文良
+        for (Map<String,Object> operationRecord : operationRecords) {
+            //第一条审批记录为发起节点
+            if (isFirst) {
+
+                String creatorName = UtilMap.getString(operationRecord, "operatorName");
+                String ddUserId = UtilMap.getString(operationRecord, "operatorUserId");
+                String creator = getXyUserId(ddUserId);
+
+                //若无域账号
+                if (Strings.isBlank(creator)){
+                    creator = "wl.chen";//创建人域账号
+                    creatorName = "陈文良";//创建人姓名
+                }
+
+                xyTodo.setCreator(creator);
+                xyTodo.setCreatorName(creatorName);
+                xyTodo.setTitle(boName + "-" + creatorName + "-" + busNo);//标题
+
+                isFirst = false;
+
+                continue;
+            }
+
+            String type = UtilMap.getString(operationRecord, "type");
+
+            switch (type){
+                case "HISTORY":
+                    //统一待办仅展示最新 暂不考虑历史记录
+                    /*xyTodo.setOptType(2);
+
+                    xyTodo.setReceivers(UtilMap.getString(operationRecord,"operatorUserId"));//接收人域账号
+                    xyTodo.setReceiverNames(UtilMap.getString(operationRecord,"operatorName"));//接收人姓名
+                    xyTodo.setApproveDateTime(convertDateTime(UtilMap.getString(operationRecord, "operateTimeGMT")));//审批时间
+
+                    //若该代办已存在,则拼接接收人  若该代办不存在,添加该代办
+                    if (!isExist(xyTodoList, xyTodo)) {
+                        xyTodoList.add(xyTodo);
+                    }*/
+
+                    break;
+                case "TODO":
+                    xyTodo.setOptType(1);
+                    xyTodo.setCurrentStatus(UtilMap.getString(operationRecord,"showName"));//当前状况(流程描述)
+
+                    String ddUserId = UtilMap.getString(operationRecord, "operatorUserId");
+                    String receiversName = UtilMap.getString(operationRecord, "operatorName");
+                    String receivers = getXyUserId(ddUserId);
+
+                    //若无域账号
+                    if (Strings.isBlank(receivers)){
+                        receivers = "wl.chen";
+                        receiversName = "陈文良";
+                    }
+                    xyTodo.setReceivers(receivers);//接收人域账号
+                    xyTodo.setReceiverNames(receiversName);//接收人姓名
+
+                    break;
+                case "FORCAST":break;
+                default:break;
+            }
+        }
+
+        //全部已办
+        if (Strings.isBlank(xyTodo.getReceivers())){
+            //最后一个审批记录审批人设为接收人
+            Map<String, Object> operationRecord = operationRecords.get(operationRecords.size() - 1);
+
+            String ddUserId = UtilMap.getString(operationRecord, "operatorUserId");
+            String receiversName = UtilMap.getString(operationRecord, "operatorName");
+            String receivers = getXyUserId(ddUserId);
+            //若无域账号
+            if (Strings.isBlank(receivers)){
+                receivers = "wl.chen";
+                receiversName = "陈文良";
+            }
+
+            xyTodo.setReceivers(receivers);//接收人域账号
+            xyTodo.setReceiverNames(receiversName);//接收人姓名
+            xyTodo.setCurrentStatus(UtilMap.getString(operationRecord,"showName"));//当前状况(流程描述)
+
+        }
+
+        syncXyTodo(xyTodo);
+
+        return McR.success();
+    }
+
+    /**
+     * 删除宜搭待办
+     */
+    @PostMapping("/delete")
+    McR delete(@RequestBody Map map) {
+        String busNo = UtilMap.getString(map, "busNo");//唯一流水号
+
+        deleteXyTodo(busNo);
+
+        return McR.success();
+    }
+
+    @GetMapping("/test")
+    McR test(){
+        return McR.success("111");
+    }
+
+    //钉钉userId查询象屿域账号(钉钉工号)
+    private String getXyUserId(String userId){
+        DDR_New ddrNew = (DDR_New) UtilHttp.doPost("https://oapi.dingtalk.com/topapi/v2/user/get", null, ddClient.initTokenParams(), UtilMap.map("userid", userId), DDR_New.class);
+
+        Map result = (Map) ddrNew.getResult();
+
+        String xyUserId = UtilMap.getString(result, "job_number");
+
+        return xyUserId;//接收人域账号
+    }
+
+
+
+
+}

+ 146 - 0
mjava-harrison/src/main/java/com/malk/harrison/entity/XyTodo.java

@@ -0,0 +1,146 @@
+package com.malk.harrison.entity;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Data
+public class XyTodo {
+    //系统标识(每个系统和环境不同,需要联系管理员获取)
+    private String sysCode;
+
+    //密钥(每个系统和环境不同,需要联系管理员获取)
+    private String secureCode;
+
+    //唯一流水号
+    private String busNo;
+
+    //标题
+    private String title;
+
+    //业务层级类型,例如:"人力资源流程,员工出差申请",层级必须不少于二级。
+    private String busType;
+
+    //当前状况(流程描述,即当前节点名称)
+    private String currentStatus;
+
+    //待办 PC 地址,传输相对路径时需要向管理员提供唯一地址前缀,该地址需能过 OA 单点进行跳转
+    private String pcUrl;
+
+    //待办 APP 地址,传输相对路径时需要向管理员提供唯一地址前缀,该地址需能过 OA 单点进行跳转
+    private String appUrl;
+
+    //操作类型:1、待办;2、已办;3、待阅
+    //如果新的推送类型一样,本次推送不生效,所以不能通过重复推送的办法去修改例如标题,审批状态等数据,如果需要修改数据,请对接专门的修改接口
+    private int optType;
+
+    //创建人域账号
+    private String creator;
+
+    //创建人姓名
+    private String creatorName;
+
+    //创建时间,样例 : "yyyy-MM-dd HH:mm:ss"
+    private String createDateTime;
+
+    //接收人域账号,字符串形式,支持多人逗号隔开
+    private String receivers;
+
+    //接收人姓名,字符串形式,支持多人逗号隔开
+    private String receiverNames;
+
+    //接收时间,样例 : "yyyy-MM-dd HH:mm:ss"
+    private String receiveDateTime;
+
+    //是否支持批量操作
+    private boolean isSupportBatch;
+
+    //审批时间,新的数据必须大于等于数据库中的旧数据,本次推送才生效
+    private String approveDateTime;
+
+    //关键字字段(该字段为搜索条件,对单据进行定位)
+    private List<String> keywords;
+
+    //流程 id(主要用于统一待办添加关注功能,以及催办功能)
+    private String flowId;
+
+    public Map toMap() throws Exception {
+        ObjectMapper mapper = new ObjectMapper();
+        // 禁用属性名自动检测,直接使用字段名
+        mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
+        mapper.setVisibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE);
+        return mapper.convertValue(this, Map.class);
+    }
+
+    public XyTodo() {
+    }
+
+    public XyTodo(String sysCode, String secureCode, String busType) {
+        this.sysCode = sysCode;
+        this.secureCode = secureCode;
+        this.busType = busType;
+    }
+
+    public void setCurrentStatus(String currentStatus) {
+        if (Objects.nonNull(this.currentStatus)){
+            String[] split = this.currentStatus.split(",");
+
+            boolean isExist = false;
+            for (String s : split) {
+                if (s.equals(currentStatus)) {
+                    isExist = true;
+                }
+            }
+
+            if (!isExist) {
+                this.currentStatus += "," + currentStatus;
+            }
+        }else {
+            this.currentStatus = currentStatus;
+        }
+    }
+
+    public void setReceiverNames(String receiverNames) {
+        if (Objects.nonNull(this.receiverNames)){
+            String[] split = this.receiverNames.split(",");
+
+            boolean isExist = false;
+            for (String s : split) {
+                if (s.equals(receiverNames)) {
+                    isExist = true;
+                }
+            }
+
+            if (!isExist) {
+                this.receiverNames += "," + receiverNames;
+            }
+        }else {
+            this.receiverNames = receiverNames;
+        }
+    }
+
+    public void setReceivers(String receivers) {
+        if (Objects.nonNull(this.receivers)){
+            String[] split = this.receivers.split(",");
+
+            boolean isExist = false;
+            for (String s : split) {
+                if (s.equals(receivers)) {
+                    isExist = true;
+                }
+            }
+
+            if (!isExist) {
+                this.receivers += "," + receivers;
+            }
+        }else{
+            this.receivers = receivers;
+        }
+
+    }
+}

+ 30 - 0
mjava-harrison/src/main/java/com/malk/harrison/server/XYR.java

@@ -0,0 +1,30 @@
+package com.malk.harrison.server;
+
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+
+/**
+ * 返回数据_阿里云
+ */
+@Data
+public class XYR<T> extends VenR {
+
+    private boolean result;
+
+    private String code;
+
+    private String message;
+
+    private String description;
+
+    private T data;
+
+    /**
+     * 断言错误信息
+     */
+    @Override
+    public void assertSuccess() {
+        McException.assertException(!result, code, message + " >>> " + description, "象屿统一待办");
+    }
+}

+ 25 - 0
mjava-harrison/src/main/resources/application-dev.yml.example

@@ -0,0 +1,25 @@
+# 复制为 application-dev.yml 后按开发环境填写;真实文件已被 .gitignore 排除。
+spring:
+  datasource:
+    hikari:
+      connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    username: ${DB_USERNAME:root}
+    password: ${DB_PASSWORD:}
+    url: ${DB_URL:jdbc:mysql://127.0.0.1:3306/mjava_harrison?useUnicode=true&characterEncoding=utf8}
+  jpa:
+    database: MYSQL
+    database-platform: org.hibernate.dialect.MySQL57Dialect
+    show-sql: false
+
+file:
+  path:
+    file: ${FILE_PATH:./data/files}
+    image: ${IMAGE_PATH:./data/images}
+    tmp: ${TMP_PATH:./data/tmp}
+  source:
+    fonts: ${FONTS_PATH:./data/fonts}
+
+logging:
+  file:
+    path: ${LOG_PATH:./logs}

+ 25 - 0
mjava-harrison/src/main/resources/application-prod.yml.example

@@ -0,0 +1,25 @@
+# 复制为 application-prod.yml 后部署;所有敏感值均由环境变量注入。
+spring:
+  datasource:
+    hikari:
+      connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    username: ${DB_USERNAME}
+    password: ${DB_PASSWORD}
+    url: ${DB_URL}
+  jpa:
+    database: MYSQL
+    database-platform: org.hibernate.dialect.MySQL57Dialect
+    show-sql: false
+
+file:
+  path:
+    file: ${FILE_PATH}
+    image: ${IMAGE_PATH}
+    tmp: ${TMP_PATH}
+  source:
+    fonts: ${FONTS_PATH}
+
+logging:
+  file:
+    path: ${LOG_PATH}

+ 39 - 0
mjava-harrison/src/main/resources/application.yml

@@ -0,0 +1,39 @@
+server:
+  port: ${SERVER_PORT:9020}
+  servlet:
+    context-path: /api/harrison
+
+spel:
+  scheduling: false
+  multiSource: false
+
+spring:
+  profiles:
+    active: ${SPRING_PROFILES_ACTIVE:dev}
+  jpa:
+    hibernate:
+      ddl-auto: none
+    open-in-view: false
+
+dingtalk:
+  agentId: ${DINGTALK_AGENT_ID:}
+  appKey: ${DINGTALK_APP_KEY:}
+  appSecret: ${DINGTALK_APP_SECRET:}
+  corpId: ${DINGTALK_CORP_ID:}
+  aesKey: ${DINGTALK_AES_KEY:}
+  token: ${DINGTALK_CALLBACK_TOKEN:}
+  operator: ${DINGTALK_OPERATOR:}
+
+aliwork:
+  appType: ${ALIWORK_APP_TYPE:}
+  systemToken: ${ALIWORK_SYSTEM_TOKEN:}
+
+harrison:
+  xiangyu:
+    baseUrl: ${HARRISON_XIANGYU_BASE_URL:}
+    system: ${HARRISON_XIANGYU_SYSTEM:}
+    secure: ${HARRISON_XIANGYU_SECURE:}
+    apiKey: ${HARRISON_XIANGYU_API_KEY:}
+  yida:
+    appType: ${HARRISON_YIDA_APP_TYPE:}
+    systemToken: ${HARRISON_YIDA_SYSTEM_TOKEN:}

BIN
mjava-harrison/src/main/resources/templates/FLOURISH 销售发票.xlsx


BIN
mjava-harrison/src/main/resources/templates/HARRISON 海运-物流服务.xlsx


BIN
mjava-harrison/src/main/resources/templates/HARRISON 贸易-吨度.xlsx


BIN
mjava-harrison/src/main/resources/templates/HARRISON 贸易-湿吨.xlsx


BIN
mjava-harrison/src/main/resources/templates/HARRISON 贸易-磅价.xlsx


BIN
mjava-harrison/src/main/resources/templates/HARRISON 通用-加工费.xlsx


BIN
mjava-harrison/src/main/resources/templates/HARRISON 通用模板.xlsx


BIN
mjava-harrison/src/main/resources/templates/VIC 贸易-吨度.xlsx


BIN
mjava-harrison/src/main/resources/templates/VIC 贸易-干吨价.xlsx


BIN
mjava-harrison/src/main/resources/templates/VIC 贸易-磅价.xlsx


BIN
mjava-harrison/src/main/resources/templates/VIC 通用-联检费.xlsx


BIN
mjava-harrison/src/main/resources/templates/VIC 通用模板.xlsx


BIN
mjava-harrison/src/main/resources/templates/VLINK 海运-拖车费.xlsx


BIN
mjava-harrison/src/main/resources/templates/VLINK 贸易-佣金.xlsx


BIN
mjava-harrison/src/main/resources/templates/VLINK 贸易-加工费.xlsx


BIN
mjava-harrison/src/main/resources/templates/VLINK 贸易-尾矿.xlsx


BIN
mjava-harrison/src/main/resources/templates/VLINK 贸易-干吨价.xlsx


BIN
mjava-harrison/src/main/resources/templates/VLINK 贸易-湿吨.xlsx


BIN
mjava-harrison/src/main/resources/templates/VLINK 通用模板.xlsx


BIN
mjava-harrison/src/main/resources/templates/ZEEK 物流-拖车费.xlsx


+ 39 - 0
mjava-harrison/src/test/java/com/malk/harrison/config/HarrisonConfigTest.java

@@ -0,0 +1,39 @@
+package com.malk.harrison.config;
+
+import org.junit.Test;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Harrison 环境配置绑定测试。
+ */
+public class HarrisonConfigTest {
+
+    @Test
+    public void bind_nestedCredentials_returnsConfiguredValues() {
+        Map<String, Object> properties = new HashMap<>();
+        properties.put("harrison.xiangyu.base-url", "https://example.test/api");
+        properties.put("harrison.xiangyu.system", "system-test");
+        properties.put("harrison.xiangyu.secure", "secure-test");
+        properties.put("harrison.xiangyu.api-key", "api-key-test");
+        properties.put("harrison.yida.app-type", "APP_TEST");
+        properties.put("harrison.yida.system-token", "token-test");
+
+        HarrisonConfig config = new Binder(new MapConfigurationPropertySource(properties))
+                .bind("harrison", Bindable.of(HarrisonConfig.class))
+                .get();
+
+        assertEquals("https://example.test/api", config.getXiangyu().getBaseUrl());
+        assertEquals("system-test", config.getXiangyu().getSystem());
+        assertEquals("secure-test", config.getXiangyu().getSecure());
+        assertEquals("api-key-test", config.getXiangyu().getApiKey());
+        assertEquals("APP_TEST", config.getYida().getAppType());
+        assertEquals("token-test", config.getYida().getSystemToken());
+    }
+}

+ 35 - 0
mjava-harrison/src/test/resources/server.sh

@@ -0,0 +1,35 @@
+#!/bin/bash
+appname='mjava-harrison'
+if [ "$1" == "dev" ]; then
+  java -Xms256m -Xmx256m -jar $appname.jar --spring.profiles.active=dev
+else
+  if [ "$1" == "start" ]; then
+    nohup java -Xms256m -Xmx256m -jar $appname.jar &
+    echo "server prod is starting"
+  else
+    if [ "$1" == "test" ]; then
+      nohup java -Xms256m -Xmx256m -jar $appname.jar --spring.profiles.active=test &
+      echo "server test is starting"
+    else
+      if [ "$1" == "stop" ]; then
+        PID=$(ps -ef | grep $appname.jar | grep -v grep | awk '{ print $2 }')
+        if [ -z "$PID" ]; then
+          echo "server is already stopped"
+        else
+          echo kill $PID
+          kill $PID
+        fi
+      else
+        if [ "$1" == "status" ]; then
+          PID=$(ps -ef | grep $appname.jar | grep -v grep | awk '{ print $2 }')
+          if [ -z "$PID" ]; then
+            echo "server is stopped"
+          else
+            echo "server is running"
+            echo $PID
+          fi
+        fi
+      fi
+    fi
+  fi
+fi

+ 2 - 0
pom.xml

@@ -14,6 +14,8 @@
         <module>mjava-pro</module>
         <module>mjava-com</module>
         <module>mjava-benteler</module>
+        <module>mjava-harrison</module>
+        <module>mjava-guangming</module>
     </modules>
     <packaging>pom</packaging>