Commit 83b2d59fadd11957ead236191804ca83512636a1

Authored by 欧保权
0 parents

简道云质量域风险场景对接

Showing 21 changed files with 4160 additions and 0 deletions
  1 +# =============================================================================
  2 +# 风险场景统计配置(外显配置,放在 jar 运行目录,Spring Boot 自动加载)
  3 +# 修改本文件后无需重新打包,重启应用即可生效。
  4 +#
  5 +# 新增场景:复制一段 jdy-risk.scenarios[n].xxx,n 递增,按场景类型填字段:
  6 +# type=scenario1:全量,满足 总计>=5 且 差值>=1 且 flowState=in-progress-state
  7 +# 需填 total-field / diff-field / in-progress-state
  8 +# type=scenario2:当月,按 group-field 分组,SI数量/问题数量合计>60% 且 合计>=5 的分组数
  9 +# 需填 date-field / group-field / si-field / qty-field / month-only
  10 +# type=scenario3:当月,SI占比>60% 且 总问题数>=5 的行数
  11 +# 需填 date-field / total-field / ratio-field / month-only
  12 +# 每个场景的 sync-code 对应启效目标记录的 syncCode,统计结果写入 qx.count-field 字段。
  13 +# =============================================================================
  14 +
  15 +# ---------- 简道云(长安) ----------
  16 +jdy-risk.host=https://jdy.changan.com.cn/api
  17 +jdy-risk.api-key=lOnr4ms8jfc0gbCwVYS7YtbYQXjBFq979Cc06BcC866427b2a7aAd054598Bc7bB
  18 +
  19 +# ---------- 启效(QX) 写回目标 ----------
  20 +jdy-risk.qx.base-url=https://hfca.uat3.qgutech.com
  21 +jdy-risk.qx.username=15077901670
  22 +jdy-risk.qx.password=000000
  23 +jdy-risk.qx.corp-code=hfca
  24 +jdy-risk.qx.app-id=sEOSZ8EPwdrzJB41amD
  25 +jdy-risk.qx.query-code=ilr3ne7bPage
  26 +jdy-risk.qx.sync-query-param=pcqqjoquery
  27 +jdy-risk.qx.update-code=11a41Update
  28 +jdy-risk.qx.count-field=chcbxt
  29 +jdy-risk.qx.attach-field=hciwkz
  30 +
  31 +# ---------- 场景1:3mis 风险监控(全量) ----------
  32 +jdy-risk.scenarios[0].id=count_1
  33 +jdy-risk.scenarios[0].name=3mis风险监控
  34 +jdy-risk.scenarios[0].type=scenario1
  35 +jdy-risk.scenarios[0].sync-code=SYNC0001
  36 +jdy-risk.scenarios[0].app-id=65fd5c0e0d1f1a7ae4f038ec
  37 +jdy-risk.scenarios[0].entry-id=696f1754f141344a6b794f9c
  38 +jdy-risk.scenarios[0].total-field=_widget_1768888148020
  39 +jdy-risk.scenarios[0].diff-field=_widget_1768888148019
  40 +jdy-risk.scenarios[0].in-progress-state=0
  41 +jdy-risk.scenarios[0].month-only=false
  42 +
  43 +# ---------- 场景2:供应商 SI 占比风险监控(当月,按供应商分组) ----------
  44 +jdy-risk.scenarios[1].id=count_2
  45 +jdy-risk.scenarios[1].name=供应商SI占比风险监控
  46 +jdy-risk.scenarios[1].type=scenario2
  47 +jdy-risk.scenarios[1].sync-code=SYNC0002
  48 +jdy-risk.scenarios[1].app-id=65f8060f8666b8a5d1b98d57
  49 +jdy-risk.scenarios[1].entry-id=6a03dd3386f7148aadb98990
  50 +jdy-risk.scenarios[1].date-field=_widget_1778638131411
  51 +jdy-risk.scenarios[1].group-field=_widget_1778638131431
  52 +jdy-risk.scenarios[1].si-field=_widget_1778638131430
  53 +jdy-risk.scenarios[1].qty-field=_widget_1778638131418
  54 +jdy-risk.scenarios[1].month-only=true
  55 +
  56 +# ---------- 场景3:工程师 SI 占比风险监控(当月) ----------
  57 +jdy-risk.scenarios[2].id=count_3
  58 +jdy-risk.scenarios[2].name=工程师SI占比风险监控
  59 +jdy-risk.scenarios[2].type=scenario3
  60 +jdy-risk.scenarios[2].sync-code=SYNC0003
  61 +jdy-risk.scenarios[2].app-id=65f8060f8666b8a5d1b98d57
  62 +jdy-risk.scenarios[2].entry-id=6a193f03e88046f554dc10bc
  63 +jdy-risk.scenarios[2].date-field=_widget_1780039427416
  64 +jdy-risk.scenarios[2].total-field=_widget_1780039427422
  65 +jdy-risk.scenarios[2].ratio-field=_widget_1780039778663
  66 +jdy-risk.scenarios[2].month-only=true
  67 +jdy-risk.scenarios[2].extra-field=_widget_1780039427417
  68 +
  69 +# ---------- 场景4:CR导出-按供应商(升级不含QR/空白,当季度) ----------
  70 +jdy-risk.scenarios[3].id=count_4
  71 +jdy-risk.scenarios[3].name=供应商升级风险监控
  72 +jdy-risk.scenarios[3].type=scenario4
  73 +jdy-risk.scenarios[3].sync-code=SYNC0004
  74 +jdy-risk.scenarios[3].app-id=65f8060f8666b8a5d1b98d57
  75 +jdy-risk.scenarios[3].entry-id=6875b1ed729e8f4038955c62
  76 +jdy-risk.scenarios[3].date-field=_widget_1752543725802
  77 +jdy-risk.scenarios[3].group-field=_widget_1752543725816
  78 +jdy-risk.scenarios[3].up-field=_widget_1752543725813
  79 +jdy-risk.scenarios[3].exclude-values[0]=QR
  80 +jdy-risk.scenarios[3].min-count=3
  81 +jdy-risk.scenarios[3].quarter-only=true
  82 +
  83 +# ---------- 场景5:CR导出-按IQ工程师(升级不含QR/空白,当季度) ----------
  84 +jdy-risk.scenarios[4].id=count_5
  85 +jdy-risk.scenarios[4].name=工程师升级风险监控
  86 +jdy-risk.scenarios[4].type=scenario5
  87 +jdy-risk.scenarios[4].sync-code=SYNC0005
  88 +jdy-risk.scenarios[4].app-id=65f8060f8666b8a5d1b98d57
  89 +jdy-risk.scenarios[4].entry-id=6875b1ed729e8f4038955c62
  90 +jdy-risk.scenarios[4].date-field=_widget_1752543725802
  91 +jdy-risk.scenarios[4].group-field=_widget_1752543725822
  92 +jdy-risk.scenarios[4].up-field=_widget_1752543725813
  93 +jdy-risk.scenarios[4].exclude-values[0]=QR
  94 +jdy-risk.scenarios[4].min-count=3
  95 +jdy-risk.scenarios[4].quarter-only=true
  96 +
  97 +# ---------- 场景6:三包索赔申诉台账-经销商(当年,次数>=3) ----------
  98 +jdy-risk.scenarios[5].id=count_6
  99 +jdy-risk.scenarios[5].name=经销商三包索赔风险监控
  100 +jdy-risk.scenarios[5].type=scenario6
  101 +jdy-risk.scenarios[5].sync-code=SYNC0007
  102 +jdy-risk.scenarios[5].app-id=65f8060f8666b8a5d1b98d57
  103 +jdy-risk.scenarios[5].entry-id=688336be2fbf78b3d44d7174
  104 +jdy-risk.scenarios[5].date-field=_widget_1753429694910
  105 +jdy-risk.scenarios[5].group-field=_widget_1753429694911
  106 +jdy-risk.scenarios[5].min-count=3
  107 +jdy-risk.scenarios[5].year-only=true
  108 +
  109 +# ---------- 场景7:三包库房进库-超期未处理(180天,排除已出库/已抵扣) ----------
  110 +jdy-risk.scenarios[6].id=count_7
  111 +jdy-risk.scenarios[6].name=三包库房超期未处理风险监控
  112 +jdy-risk.scenarios[6].type=scenario7
  113 +jdy-risk.scenarios[6].sync-code=SYNC0008
  114 +jdy-risk.scenarios[6].app-id=65f8060f8666b8a5d1b98d57
  115 +jdy-risk.scenarios[6].entry-id=686b6f72fcb3f02a8ffc8fcd
  116 +jdy-risk.scenarios[6].barcode-field=_widget_1751871346824
  117 +jdy-risk.scenarios[6].date-field=_widget_1751871346820
  118 +jdy-risk.scenarios[6].max-age-days=180
  119 +# 主表只取这几列(经销商代码/经销商名称/入库时间/条码),减小传输
  120 +jdy-risk.scenarios[6].fields[0]=_widget_1751871346816
  121 +jdy-risk.scenarios[6].fields[1]=_widget_1751871346817
  122 +jdy-risk.scenarios[6].fields[2]=_widget_1751871346820
  123 +jdy-risk.scenarios[6].fields[3]=_widget_1751871346824
  124 +jdy-risk.scenarios[6].exclude-forms[0].entry-id=686b6fd1e3cdf473acade9ac
  125 +jdy-risk.scenarios[6].exclude-forms[0].barcode-field=_widget_1751871441626
  126 +jdy-risk.scenarios[6].exclude-forms[1].entry-id=686b781c11159446bb2583fc
  127 +jdy-risk.scenarios[6].exclude-forms[1].barcode-field=_widget_1751873565337
  128 +
  129 +# ---------- 场景8:料废登记台账-供应商(上月,未开CR占比>70%) ----------
  130 +jdy-risk.scenarios[7].id=count_8
  131 +jdy-risk.scenarios[7].name=供应商未开CR风险监控
  132 +jdy-risk.scenarios[7].type=scenario8
  133 +jdy-risk.scenarios[7].sync-code=SYNC0010
  134 +jdy-risk.scenarios[7].app-id=65f8060f8666b8a5d1b98d57
  135 +jdy-risk.scenarios[7].entry-id=69676b83f96b846da4443c42
  136 +jdy-risk.scenarios[7].date-field=_widget_1768385411429
  137 +jdy-risk.scenarios[7].group-field=_widget_1783921957534
  138 +jdy-risk.scenarios[7].flag-field=_widget_1769502015250
  139 +jdy-risk.scenarios[7].flag-value=\u5426
  140 +jdy-risk.scenarios[7].ratio-threshold=0.7
  141 +jdy-risk.scenarios[7].prev-month-only=true
  142 +
  143 +# ---------- 场景9:料废登记台账-工程师(上月,未开CR占比>70%) ----------
  144 +jdy-risk.scenarios[8].id=count_9
  145 +jdy-risk.scenarios[8].name=工程师未开CR风险监控
  146 +jdy-risk.scenarios[8].type=scenario9
  147 +jdy-risk.scenarios[8].sync-code=SYNC0011
  148 +jdy-risk.scenarios[8].app-id=65f8060f8666b8a5d1b98d57
  149 +jdy-risk.scenarios[8].entry-id=69676b83f96b846da4443c42
  150 +jdy-risk.scenarios[8].date-field=_widget_1768385411429
  151 +jdy-risk.scenarios[8].group-field=_widget_1769498221213
  152 +jdy-risk.scenarios[8].flag-field=_widget_1769502015250
  153 +jdy-risk.scenarios[8].flag-value=\u5426
  154 +jdy-risk.scenarios[8].ratio-threshold=0.7
  155 +jdy-risk.scenarios[8].prev-month-only=true
  156 +
  157 +# =============================================================================
  158 +# 表单同步配置(简道云表单结构/数据 -> 启效指定应用)
  159 +# 结构同步接口:POST /api/jdy/form/sync-structure
  160 +# 数据同步接口:POST /api/jdy/form/sync-data(当月数据,当天已存在则先删当天再同步)
  161 +# 新增表单:复制一段 jdy-form-sync.forms[n].xxx,n 递增
  162 +# =============================================================================
  163 +jdy-form-sync.jdy.host=https://jdy.changan.com.cn/api
  164 +jdy-form-sync.jdy.api-key=lOnr4ms8jfc0gbCwVYS7YtbYQXjBFq979Cc06BcC866427b2a7aAd054598Bc7bB
  165 +# 可选:指定同步月份(yyyy-MM)用于补数/测试;留空=当前自然月(仅 month-only=true 时生效)
  166 +# jdy-form-sync.month=2026-08
  167 +# Excel 导出目录(相对 jar 运行目录)
  168 +jdy-form-sync.export-dir=./export
  169 +
  170 +jdy-form-sync.qx.base-url=https://hfca.uat3.qgutech.com
  171 +jdy-form-sync.qx.username=15077901670
  172 +jdy-form-sync.qx.password=000000
  173 +jdy-form-sync.qx.corp-code=hfca
  174 +jdy-form-sync.qx.app-id=sEOSZ8EPwdrzJB41amD
  175 +jdy-form-sync.qx.app-name=风险预警平台
  176 +
  177 +# 待同步表单:3mis争议件大于确定件(合肥长安_技术处)
  178 +# 注意:表单名默认从简道云接口取(避免 .properties 的 ISO-8859-1 中文乱码),此处不写中文名
  179 +jdy-form-sync.forms[0].app-id=65fd5c0e0d1f1a7ae4f038ec
  180 +jdy-form-sync.forms[0].entry-id=696f1754f141344a6b794f9c
  181 +jdy-form-sync.forms[0].fun-code=misDispute
  182 +# 已有模型 id:填了则原地更新(不新建)。首次同步可留空
  183 +jdy-form-sync.forms[0].model-id=P1Vgt89QvFStKh7rDzl
  184 +# 日期字段(用于「当月」过滤):简道云 日期时间
  185 +jdy-form-sync.forms[0].date-field=_widget_1773622123700
  186 +# 是否只同步当月;false=同步全部数据(本表单改为全量)
  187 +jdy-form-sync.forms[0].month-only=false
  188 +
... ...
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<project xmlns="http://maven.apache.org/POM/4.0.0"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  5 + <modelVersion>4.0.0</modelVersion>
  6 +
  7 + <groupId>com.qgutech.qgyun.apaas</groupId>
  8 + <artifactId>qx-apaas-sync</artifactId>
  9 + <version>1.0.0-SNAPSHOT</version>
  10 + <packaging>jar</packaging>
  11 + <name>qx-apaas-sync</name>
  12 + <description>简道云 -> 启效 独立同步工具(无中间件依赖,java -jar 运行)</description>
  13 +
  14 + <parent>
  15 + <groupId>org.springframework.boot</groupId>
  16 + <artifactId>spring-boot-starter-parent</artifactId>
  17 + <version>2.0.9.RELEASE</version>
  18 + <relativePath/>
  19 + </parent>
  20 +
  21 + <properties>
  22 + <java.version>1.8</java.version>
  23 + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  24 + <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  25 + <fastjson.version>2.0.6</fastjson.version>
  26 + <httpclient.version>4.5.14</httpclient.version>
  27 + <poi.version>4.1.2</poi.version>
  28 + </properties>
  29 +
  30 + <dependencies>
  31 + <!-- Web:提供内嵌 Tomcat 容器 + Spring MVC(REST 触发接口),非外部中间件 -->
  32 + <dependency>
  33 + <groupId>org.springframework.boot</groupId>
  34 + <artifactId>spring-boot-starter-web</artifactId>
  35 + </dependency>
  36 +
  37 + <!-- JSON 处理 -->
  38 + <dependency>
  39 + <groupId>com.alibaba</groupId>
  40 + <artifactId>fastjson</artifactId>
  41 + <version>${fastjson.version}</version>
  42 + </dependency>
  43 +
  44 + <!-- HTTP 客户端(简道云 / 启效写侧调用) -->
  45 + <dependency>
  46 + <groupId>org.apache.httpcomponents</groupId>
  47 + <artifactId>httpclient</artifactId>
  48 + <version>${httpclient.version}</version>
  49 + </dependency>
  50 +
  51 + <!-- multipart(启效附件上传) -->
  52 + <dependency>
  53 + <groupId>org.apache.httpcomponents</groupId>
  54 + <artifactId>httpmime</artifactId>
  55 + <version>${httpclient.version}</version>
  56 + </dependency>
  57 +
  58 + <!-- Excel 处理(导出简道云数据) -->
  59 + <dependency>
  60 + <groupId>org.apache.poi</groupId>
  61 + <artifactId>poi-ooxml</artifactId>
  62 + <version>${poi.version}</version>
  63 + </dependency>
  64 +
  65 + <!-- 简化代码 -->
  66 + <dependency>
  67 + <groupId>org.projectlombok</groupId>
  68 + <artifactId>lombok</artifactId>
  69 + <version>1.18.20</version>
  70 + <scope>provided</scope>
  71 + </dependency>
  72 +
  73 + <!-- 测试 -->
  74 + <dependency>
  75 + <groupId>org.springframework.boot</groupId>
  76 + <artifactId>spring-boot-starter-test</artifactId>
  77 + <scope>test</scope>
  78 + </dependency>
  79 + </dependencies>
  80 +
  81 + <build>
  82 + <plugins>
  83 + <plugin>
  84 + <groupId>org.springframework.boot</groupId>
  85 + <artifactId>spring-boot-maven-plugin</artifactId>
  86 + <executions>
  87 + <execution>
  88 + <goals>
  89 + <goal>repackage</goal>
  90 + </goals>
  91 + </execution>
  92 + </executions>
  93 + </plugin>
  94 + <plugin>
  95 + <groupId>org.apache.maven.plugins</groupId>
  96 + <artifactId>maven-compiler-plugin</artifactId>
  97 + <configuration>
  98 + <source>${java.version}</source>
  99 + <target>${java.version}</target>
  100 + <encoding>${project.build.sourceEncoding}</encoding>
  101 + </configuration>
  102 + </plugin>
  103 + </plugins>
  104 + </build>
  105 +</project>
... ...
  1 +package com.qgutech.qgyun.apaas.sync;
  2 +
  3 +import org.springframework.boot.SpringApplication;
  4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
  5 +
  6 +/**
  7 + * 简道云 -> 启效 独立同步工具启动类。
  8 + * <p>
  9 + * 无中间件依赖:单 jar(内嵌 Tomcat)+ 仅 Java 环境即可运行。配置在 application.yml(jdy-sync.*)。
  10 + */
  11 +@SpringBootApplication
  12 +public class QxApaasSyncApplication {
  13 +
  14 + public static void main(String[] args) {
  15 + SpringApplication.run(QxApaasSyncApplication.class, args);
  16 + }
  17 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.api;
  2 +
  3 +import com.alibaba.fastjson.JSONArray;
  4 +import com.alibaba.fastjson.JSONObject;
  5 +import com.qgutech.qgyun.apaas.sync.jdy.util.JsonHttpUtils;
  6 +import lombok.extern.slf4j.Slf4j;
  7 +
  8 +import java.util.Arrays;
  9 +import java.util.HashMap;
  10 +import java.util.List;
  11 +import java.util.Map;
  12 +
  13 +/**
  14 + * 简道云开放平台 v5 客户端。
  15 + * <p>
  16 + * 鉴权:{@code Authorization: Bearer <apiKey>}。HOST:
  17 + * 公有云 {@code https://api.jiandaoyun.com/api};独立域名/独享版 {@code https://<域名>/api}。
  18 + * <p>
  19 + * 普通类,由 JdyToQxSyncService 按配置实例化。
  20 + */
  21 +@Slf4j
  22 +public class JiandaoYunApi {
  23 +
  24 + private final String host;
  25 +
  26 + public JiandaoYunApi(String host) {
  27 + this.host = host == null || host.isEmpty() ? "https://api.jiandaoyun.com/api" : stripSlash(host);
  28 + }
  29 +
  30 + private static String stripSlash(String s) {
  31 + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
  32 + }
  33 +
  34 + /**
  35 + * 按名称在权限内查找应用,返回 app_id;找不到返回 null。
  36 + */
  37 + public String findAppId(String apiKey, String appName) {
  38 + JSONObject resp = JsonHttpUtils.postJson2Obj(host + "/v5/app/list",
  39 + JsonHttpUtils.bearer(apiKey), json("skip", 0, "limit", 100));
  40 + JSONArray apps = resp.getJSONArray("apps");
  41 + if (apps == null) {
  42 + return null;
  43 + }
  44 + for (int i = 0; i < apps.size(); i++) {
  45 + JSONObject app = apps.getJSONObject(i);
  46 + if (appName.equals(app.getString("name"))) {
  47 + return app.getString("app_id");
  48 + }
  49 + }
  50 + return null;
  51 + }
  52 +
  53 + /**
  54 + * 应用下表单列表。返回元素的字段:name, app_id, entry_id。
  55 + */
  56 + public JSONArray listForms(String apiKey, String appId) {
  57 + JSONObject resp = JsonHttpUtils.postJson2Obj(host + "/v5/app/entry/list",
  58 + JsonHttpUtils.bearer(apiKey), json("app_id", appId, "skip", 0, "limit", 100));
  59 + return resp.getJSONArray("forms") == null ? new JSONArray() : resp.getJSONArray("forms");
  60 + }
  61 +
  62 + /**
  63 + * 分页拉取应用下全部表单(v5 /v5/app/entry/list 单页 limit 上限 100,需按 skip 翻页直到返回不足一页)。
  64 + */
  65 + public JSONArray listAllForms(String apiKey, String appId) {
  66 + JSONArray all = new JSONArray();
  67 + int skip = 0;
  68 + int limit = 100;
  69 + while (true) {
  70 + JSONObject resp = JsonHttpUtils.postJson2Obj(host + "/v5/app/entry/list",
  71 + JsonHttpUtils.bearer(apiKey), json("app_id", appId, "skip", skip, "limit", limit));
  72 + JSONArray forms = resp.getJSONArray("forms");
  73 + if (forms == null || forms.isEmpty()) {
  74 + break;
  75 + }
  76 + all.addAll(forms);
  77 + if (forms.size() < limit) {
  78 + break;
  79 + }
  80 + skip += limit;
  81 + }
  82 + return all;
  83 + }
  84 +
  85 + /**
  86 + * 表单字段(控件)列表。字段:name(=表单字段code), label(中文名), type。
  87 + */
  88 + public JSONArray listWidgets(String apiKey, String appId, String entryId) {
  89 + JSONObject resp = JsonHttpUtils.postJson2Obj(host + "/v5/app/entry/widget/list",
  90 + JsonHttpUtils.bearer(apiKey), json("app_id", appId, "entry_id", entryId));
  91 + return resp.getJSONArray("widgets") == null ? new JSONArray() : resp.getJSONArray("widgets");
  92 + }
  93 +
  94 + /**
  95 + * 表单数据列表(单页)。
  96 + */
  97 + public JSONArray listData(String apiKey, String appId, String entryId, int limit) {
  98 + JSONObject resp = JsonHttpUtils.postJson2Obj(host + "/v5/app/entry/data/list",
  99 + JsonHttpUtils.bearer(apiKey), dataPageBody(appId, entryId, limit, null));
  100 + return resp.getJSONArray("data") == null ? new JSONArray() : resp.getJSONArray("data");
  101 + }
  102 +
  103 + /** 带限流重试的数据拉取(遇到 code=8303 频率限制时退避重试)。fields 非空时只取这些列。 */
  104 + private JSONObject dataList(String apiKey, String appId, String entryId, String dataId, List<String> fields) {
  105 + Map<String, Object> body = new HashMap<>();
  106 + body.put("app_id", appId);
  107 + body.put("entry_id", entryId);
  108 + body.put("limit", 100);
  109 + if (dataId != null && !dataId.isEmpty()) {
  110 + body.put("data_id", dataId);
  111 + }
  112 + if (fields != null && !fields.isEmpty()) {
  113 + body.put("fields", fields);
  114 + }
  115 + RuntimeException last = null;
  116 + for (int attempt = 1; attempt <= 8; attempt++) {
  117 + JSONObject resp;
  118 + try {
  119 + resp = JsonHttpUtils.postJson2Obj(host + "/v5/app/entry/data/list",
  120 + JsonHttpUtils.bearer(apiKey), body);
  121 + } catch (RuntimeException ex) {
  122 + long wait = 3000L * attempt;
  123 + log.warn("简道云请求异常({}),{}ms 后重试(第{}次)", ex.getMessage(), wait, attempt);
  124 + try {
  125 + Thread.sleep(wait);
  126 + } catch (InterruptedException ignored) {
  127 + Thread.currentThread().interrupt();
  128 + }
  129 + last = ex;
  130 + continue;
  131 + }
  132 + Integer code = resp.getInteger("code");
  133 + if (code != null && code == 8303) {
  134 + long wait = 2000L * attempt;
  135 + log.warn("简道云频率限制(8303),{}ms 后重试(第{}次)", wait, attempt);
  136 + try {
  137 + Thread.sleep(wait);
  138 + } catch (InterruptedException ignored) {
  139 + Thread.currentThread().interrupt();
  140 + }
  141 + last = new IllegalStateException("简道云频率限制: " + resp.getString("msg"));
  142 + continue;
  143 + }
  144 + if (code != null && code != 0) {
  145 + throw new IllegalStateException("简道云数据拉取失败 code=" + code + " msg=" + resp.getString("msg"));
  146 + }
  147 + return resp;
  148 + }
  149 + throw last == null ? new IllegalStateException("简道云数据拉取失败") : last;
  150 + }
  151 +
  152 + private JSONObject dataList(String apiKey, String appId, String entryId, String dataId) {
  153 + return dataList(apiKey, appId, entryId, dataId, null);
  154 + }
  155 +
  156 + /**
  157 + * 全量拉取表单数据。
  158 + * <p>
  159 + * 注意:v5 /v5/app/entry/data/list 不返回 next_id,翻页用上一页最后一条的 _id 作为 data_id 继续取。
  160 + * 单页 limit 上限 100。
  161 + */
  162 + public JSONArray listAllData(String apiKey, String appId, String entryId) {
  163 + JSONArray all = new JSONArray();
  164 + String dataId = null;
  165 + int guard = 0;
  166 + int page = 0;
  167 + while (true) {
  168 + if (++guard > 100000) {
  169 + break;
  170 + }
  171 + page++;
  172 + JSONObject resp = dataList(apiKey, appId, entryId, dataId);
  173 + JSONArray data = resp.getJSONArray("data");
  174 + if (data == null || data.isEmpty()) {
  175 + break;
  176 + }
  177 + all.addAll(data);
  178 + log.info("简道云分页: app={} entry={} 第 {} 页 {} 条, 累计 {} 条",
  179 + appId, entryId, page, data.size(), all.size());
  180 + dataId = data.getJSONObject(data.size() - 1).getString("_id");
  181 + if (data.size() < 100) {
  182 + break;
  183 + }
  184 + }
  185 + log.info("简道云拉取完成: app={} entry={} 共 {} 条", appId, entryId, all.size());
  186 + return all;
  187 + }
  188 +
  189 + /**
  190 + * 构建表单数据分页请求 body;nextId 为 null 时不带 next_id(首屏),否则带 next_id 继续翻页。
  191 + */
  192 + public Map<String, Object> dataPageBody(String appId, String entryId, int limit, String nextId) {
  193 + Map<String, Object> m = new HashMap<>();
  194 + m.put("app_id", appId);
  195 + m.put("entry_id", entryId);
  196 + m.put("limit", limit);
  197 + if (nextId != null && !nextId.isEmpty()) {
  198 + m.put("next_id", nextId);
  199 + }
  200 + return m;
  201 + }
  202 +
  203 + /**
  204 + * 分页遍历表单数据(分批回调,避免一次性载入内存,适合大表)。
  205 + */
  206 + public void forEachPage(String apiKey, String appId, String entryId, java.util.function.Consumer<JSONArray> consumer) {
  207 + forEachPage(apiKey, appId, entryId, null, consumer);
  208 + }
  209 +
  210 + /** 分页遍历表单数据(可只取指定 fields,分批回调,适合大表)。 */
  211 + public void forEachPage(String apiKey, String appId, String entryId, List<String> fields,
  212 + java.util.function.Consumer<JSONArray> consumer) {
  213 + String dataId = null;
  214 + int guard = 0;
  215 + while (true) {
  216 + if (++guard > 1000000) {
  217 + break;
  218 + }
  219 + JSONObject resp = dataList(apiKey, appId, entryId, dataId, fields);
  220 + JSONArray data = resp.getJSONArray("data");
  221 + if (data == null || data.isEmpty()) {
  222 + break;
  223 + }
  224 + consumer.accept(data);
  225 + dataId = data.getJSONObject(data.size() - 1).getString("_id");
  226 + if (data.size() < 100) {
  227 + break;
  228 + }
  229 + }
  230 + }
  231 +
  232 + public String getHost() {
  233 + return host;
  234 + }
  235 +
  236 + private static Map<String, Object> json(Object... kv) {
  237 + Map<String, Object> m = new HashMap<>();
  238 + for (int i = 0; i < kv.length; i += 2) {
  239 + m.put(String.valueOf(kv[i]), kv[i + 1]);
  240 + }
  241 + return m;
  242 + }
  243 +
  244 + public static List<String> DATE_JDY_TYPES = Arrays.asList("datetime", "date");
  245 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.api;
  2 +
  3 +import com.alibaba.fastjson.JSONArray;
  4 +import com.alibaba.fastjson.JSONObject;
  5 +import com.qgutech.qgyun.apaas.sync.jdy.util.JsonHttpUtils;
  6 +import lombok.extern.slf4j.Slf4j;
  7 +import org.apache.http.client.methods.HttpPost;
  8 +import org.apache.http.entity.ContentType;
  9 +import org.apache.http.entity.mime.MultipartEntityBuilder;
  10 +
  11 +import java.io.File;
  12 +import java.nio.charset.StandardCharsets;
  13 +import java.security.MessageDigest;
  14 +import java.util.ArrayList;
  15 +import java.util.HashMap;
  16 +import java.util.LinkedHashMap;
  17 +import java.util.List;
  18 +import java.util.Map;
  19 +
  20 +/**
  21 + * 启效(QX) v3 写侧客户端(HTTP 直连,可配置 base-url)。
  22 + * <p>
  23 + * 关键:鉴权头 {@code Authorization: <token>}(不带 Bearer、不带 businesscode),请求必须带 User-Agent。
  24 + * 写数据用表单「新增接口」{@code POST /qx-api/qx-apaas-run/ar/{appId}/{code}},body 直接用 fieldKey,日期传毫秒时间戳。
  25 + * <p>
  26 + * 普通类,由 JdyToQxSyncService 按配置实例化。
  27 + */
  28 +@Slf4j
  29 +public class QxApi {
  30 +
  31 + private static final String DSN_ID = "jJT229EDx5omwj1p5Ra";
  32 +
  33 + /** 应用元数据模型(用于查询应用列表/判断应用是否已存在)。 */
  34 + private static final String APP_DSN_ID = "MRZtdB0Z7E1nh8YfXdm";
  35 + private static final String APP_MODEL_ID = "BJzqlD5ANPR4DOic4uZ";
  36 +
  37 + private final String baseUrl;
  38 +
  39 + public QxApi(String baseUrl) {
  40 + this.baseUrl = baseUrl == null ? "" : (baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl);
  41 + }
  42 +
  43 + /** 登录:securityImg 取动态 key -> auth/login(明文密码)。返回 token。 */
  44 + public String login(String username, String password, String corpCode) {
  45 + JSONObject img = JsonHttpUtils.getJson2Obj(baseUrl + "/qx-api/qx-apaas-uc/user/securityImg",
  46 + new HashMap<>());
  47 + String key = img.getJSONObject("data").getString("key");
  48 + if (key == null) {
  49 + throw new IllegalStateException("securityImg 取 key 失败: " + img);
  50 + }
  51 + Map<String, Object> body = new HashMap<>();
  52 + body.put("autoLogin", false);
  53 + body.put("protocol1", true);
  54 + body.put("key", key);
  55 + body.put("loginType", "PWD");
  56 + body.put("username", username);
  57 + body.put("password", password);
  58 + body.put("corpCode", corpCode == null ? "default" : corpCode);
  59 + JSONObject resp = JsonHttpUtils.postJson2Obj(baseUrl + "/qx-api/qx-apaas-auth/server/auth/login",
  60 + new HashMap<>(), body);
  61 + if (!Boolean.TRUE.equals(resp.getBoolean("success"))) {
  62 + throw new IllegalStateException("启效登录失败: " + resp);
  63 + }
  64 + log.info("启效登录成功: {} (corpCode={}, user={})", baseUrl, corpCode, username);
  65 + return resp.getString("data");
  66 + }
  67 +
  68 + /** 建应用,返回 appId。 */
  69 + public String createApp(String token, String appName) {
  70 + Map<String, Object> body = new HashMap<>();
  71 + body.put("standardName", "默认规格");
  72 + body.put("cloudStatus", "WAIT_TO_CLOUD");
  73 + body.put("sourceCorpCode", "default");
  74 + body.put("source", "DEV");
  75 + body.put("type", "NORMAL");
  76 + body.put("useStatus", "ENABLED");
  77 + body.put("appName", appName);
  78 + JSONObject resp = JsonHttpUtils.postJson2Obj(
  79 + baseUrl + "/qx-api/qx-apaas-run/rt/MRZtdB0Z7E1nh8YfXdm/m/BJzqlD5ANPR4DOic4uZ/saveOrUpdate",
  80 + JsonHttpUtils.token(token), body);
  81 + return resp.getString("data");
  82 + }
  83 +
  84 + /**
  85 + * 按应用名查询启效应用 id;不存在返回 null。
  86 + * <p>
  87 + * 应用元数据模型:appId={@link #APP_DSN_ID},modelId={@link #APP_MODEL_ID},用 search 接口分页拉取后本地匹配。
  88 + */
  89 + public String findAppByName(String token, String appName) {
  90 + if (appName == null || appName.isEmpty()) {
  91 + return null;
  92 + }
  93 + int pageNum = 1;
  94 + int pageSize = 1000;
  95 + while (true) {
  96 + Map<String, Object> body = new HashMap<>();
  97 + body.put("pageNum", pageNum);
  98 + body.put("pageSize", pageSize);
  99 + JSONObject resp = JsonHttpUtils.postJson2Obj(
  100 + baseUrl + "/qx-api/qx-apaas-run/rt/" + APP_DSN_ID + "/m/" + APP_MODEL_ID + "/search",
  101 + JsonHttpUtils.token(token), body);
  102 + JSONObject data = resp.getJSONObject("data");
  103 + if (data == null) {
  104 + return null;
  105 + }
  106 + JSONArray list = data.getJSONArray("list");
  107 + if (list == null || list.isEmpty()) {
  108 + return null;
  109 + }
  110 + for (int i = 0; i < list.size(); i++) {
  111 + JSONObject app = list.getJSONObject(i);
  112 + if (appName.equals(app.getString("appName"))) {
  113 + return app.getString("id");
  114 + }
  115 + }
  116 + Integer total = data.getInteger("total");
  117 + if (total == null || pageNum * pageSize >= total) {
  118 + return null;
  119 + }
  120 + pageNum++;
  121 + }
  122 + }
  123 +
  124 + /** 查询某应用下所有内部接口。元素含:name, code, modelId, requestType, apiPluginCode。 */
  125 + public JSONArray listApiManages(String token, String appId) {
  126 + Map<String, String> headers = new HashMap<>(JsonHttpUtils.token(token));
  127 + String url = baseUrl + "/qx-api/qx-apaas-run/ar/apicentermanagement/oysPage"
  128 + + "?pageSize=100&pageNum=1&appId=" + appId + "&dataId=" + appId + "&pluginId=" + appId;
  129 + JSONObject resp = JsonHttpUtils.postJson2Obj(url, headers, new HashMap<String, Object>() {{
  130 + put("orderBy", new ArrayList<>());
  131 + put("searchConditions", null);
  132 + }});
  133 + JSONObject data = resp.getJSONObject("data");
  134 + return data == null ? new JSONArray() : data.getJSONArray("list");
  135 + }
  136 +
  137 + /** 取某表单「新增接口」对象。 */
  138 + public JSONObject findAddApi(JSONArray apiManages, String formName) {
  139 + if (apiManages == null) {
  140 + return null;
  141 + }
  142 + for (int i = 0; i < apiManages.size(); i++) {
  143 + JSONObject api = apiManages.getJSONObject(i);
  144 + String name = api.getString("name");
  145 + if (name != null && name.contains("新增") && (name.startsWith(formName) || name.equals(formName + "新增接口"))) {
  146 + return api;
  147 + }
  148 + }
  149 + return null;
  150 + }
  151 +
  152 + /** 按名字片段取接口(如"分页查询"、"删除")。 */
  153 + public JSONObject findApiByKeyword(JSONArray apiManages, String formName, String keyword) {
  154 + if (apiManages == null) {
  155 + return null;
  156 + }
  157 + for (int i = 0; i < apiManages.size(); i++) {
  158 + JSONObject api = apiManages.getJSONObject(i);
  159 + String name = api.getString("name");
  160 + if (name != null && name.contains(keyword) && name.startsWith(formName)) {
  161 + return api;
  162 + }
  163 + }
  164 + return null;
  165 + }
  166 +
  167 + /** 建表单模型,返回 modelId。 */
  168 + public String createFormModel(String token, String appId, String funName, String funCode) {
  169 + Map<String, Object> icon = new HashMap<>();
  170 + icon.put("name", "file");
  171 + icon.put("code", "file");
  172 + icon.put("type", "system");
  173 + icon.put("isSystem", true);
  174 + icon.put("groupName", "操作");
  175 + icon.put("lib", "图标库");
  176 + icon.put("iconColor", "var(--colorPrimary)");
  177 + icon.put("bgColor", "var(--colorPrimaryBg)");
  178 +
  179 + Map<String, Object> dsnObj = new HashMap<>();
  180 + dsnObj.put("icon", icon);
  181 + dsnObj.put("standards", new ArrayList<>());
  182 + dsnObj.put("engineVersion", "3.3");
  183 + dsnObj.put("funName", funName);
  184 + dsnObj.put("appId", appId);
  185 + dsnObj.put("funCode", funCode);
  186 + dsnObj.put("dsnCode", DSN_ID);
  187 + dsnObj.put("dsnId", DSN_ID);
  188 +
  189 + Map<String, Object> body = new HashMap<>();
  190 + body.put("category", "");
  191 + body.put("dsnObj", dsnObj);
  192 + JSONObject resp = JsonHttpUtils.postJson2Obj(baseUrl + "/qx-api/qx-apaas-run/app/modelDsnV2",
  193 + JsonHttpUtils.token(token), body);
  194 + if (!Boolean.TRUE.equals(resp.getBoolean("success"))) {
  195 + throw new IllegalStateException("建表单模型失败(" + funName + "): " + resp);
  196 + }
  197 + return resp.getString("data");
  198 + }
  199 +
  200 + /** 保存设计对象(表单字段),fields 为 [{title, fieldKey, type}]。 */
  201 + public void saveDesignObj(String token, String appId, String appName, String modelId,
  202 + String funName, String funCode, List<Field> fields) {
  203 + Map<String, Object> dsnObj = new HashMap<>();
  204 + dsnObj.put("id", modelId);
  205 + dsnObj.put("appId", appId);
  206 + Map<String, Object> app = new HashMap<>();
  207 + app.put("id", appId);
  208 + app.put("dataTitle", appName);
  209 + dsnObj.put("app", app);
  210 + dsnObj.put("funName", funName);
  211 + dsnObj.put("funCode", funCode);
  212 + dsnObj.put("dsnCode", DSN_ID);
  213 +
  214 + Map<String, Object> designCfg = new HashMap<>();
  215 + List<Map<String, Object>> comps = new ArrayList<>();
  216 + for (Field f : fields) {
  217 + comps.add(buildComp(f));
  218 + }
  219 + designCfg.put("comps", comps);
  220 + designCfg.put("columns", 1);
  221 + designCfg.put("type", "QxCompFormPage");
  222 + designCfg.put("dataType", appId + "." + modelId);
  223 + designCfg.put("layout", "vertical");
  224 + designCfg.put("formGridComponent", "__inherit");
  225 + designCfg.put("formPagePadding", "__inherit");
  226 + designCfg.put("formPageBg", "__inherit");
  227 + designCfg.put("formPageBgFillMode", "cover");
  228 + designCfg.put("formPageBgOpacity", "80.0");
  229 + designCfg.put("appId", appId);
  230 + designCfg.put("modelId", modelId);
  231 + designCfg.put("winCfg", winCfg());
  232 + designCfg.put("servicesCfg", new ArrayList<>());
  233 + designCfg.put("apisCfg", new ArrayList<>());
  234 + designCfg.put("events", new ArrayList<>());
  235 + dsnObj.put("designCfg", designCfg);
  236 +
  237 + dsnObj.put("engineVersion", "3.2");
  238 + Map<String, Object> icon = new HashMap<>();
  239 + icon.put("code", "info-circle");
  240 + icon.put("iconColor", "var(--colorPrimary)");
  241 + icon.put("bgColor", "var(--colorPrimaryBg)");
  242 + dsnObj.put("icon", icon);
  243 + dsnObj.put("name", "未命名");
  244 +
  245 + Map<String, Object> body = new HashMap<>();
  246 + body.put("id", DSN_ID);
  247 + body.put("dsnObj", dsnObj);
  248 +
  249 + Map<String, String> headers = new HashMap<>(JsonHttpUtils.token(token));
  250 + headers.put("pageType", "FORM");
  251 + JSONObject resp = JsonHttpUtils.postJson2Obj(baseUrl + "/qx-api/qx-apaas-plugin/dsn/saveDsnObj",
  252 + headers, body);
  253 + if (!Boolean.TRUE.equals(resp.getBoolean("success"))) {
  254 + throw new IllegalStateException("保存字段失败(" + funName + "): " + resp);
  255 + }
  256 + }
  257 +
  258 + /** 写一条数据(新增接口)。 */
  259 + public String insertData(String token, String appId, String code, Map<String, Object> payload) {
  260 + JSONObject resp = JsonHttpUtils.postJson2Obj(
  261 + baseUrl + "/qx-api/qx-apaas-run/ar/" + appId + "/" + code,
  262 + JsonHttpUtils.token(token), payload);
  263 + if (!Boolean.TRUE.equals(resp.getBoolean("success"))) {
  264 + throw new IllegalStateException("写入数据失败: " + resp);
  265 + }
  266 + return resp.getString("data");
  267 + }
  268 +
  269 + /**
  270 + * 按 syncCode 查询一条业务数据(分页接口 + 查询参数),返回首条记录 JSONObject;无则 null。
  271 + * <p>
  272 + * 例:POST /ar/{appId}/ilr3ne7bPage?pageSize=10&pageNum=1&pcqqjoquery=SYNC0002
  273 + */
  274 + public JSONObject queryBySyncCode(String token, String appId, String queryCode,
  275 + String syncQueryParam, String syncCode) {
  276 + String url = baseUrl + "/qx-api/qx-apaas-run/ar/" + appId + "/" + queryCode
  277 + + "?pageSize=10&pageNum=1&" + syncQueryParam + "=" + urlEncode(syncCode);
  278 + log.info("查询业务数据: {}?{}={}", queryCode, syncQueryParam, syncCode);
  279 + JSONObject resp = JsonHttpUtils.postJson2Obj(url, JsonHttpUtils.token(token), new HashMap<String, Object>());
  280 + JSONObject data = resp.getJSONObject("data");
  281 + JSONArray list = data == null ? null : data.getJSONArray("list");
  282 + if (list == null || list.isEmpty()) {
  283 + log.warn("未查询到记录: syncCode={}", syncCode);
  284 + return null;
  285 + }
  286 + JSONObject rec = list.getJSONObject(0);
  287 + log.info("查询到记录: syncCode={} id={}", syncCode, rec.getString("id"));
  288 + return rec;
  289 + }
  290 +
  291 + /** 更新整条业务数据(更新接口,body 需含 id 及全部字段)。 */
  292 + public void updateData(String token, String appId, String code, JSONObject body) {
  293 + String dataId = body.getString("id");
  294 + log.info("更新业务数据: {} id={}", code, dataId);
  295 + JSONObject resp = JsonHttpUtils.postJson2Obj(
  296 + baseUrl + "/qx-api/qx-apaas-run/ar/" + appId + "/" + code,
  297 + JsonHttpUtils.token(token), body);
  298 + if (!Boolean.TRUE.equals(resp.getBoolean("success"))) {
  299 + throw new IllegalStateException("更新数据失败: " + resp);
  300 + }
  301 + log.info("更新成功: id={}", dataId);
  302 + }
  303 +
  304 + private static String urlEncode(String s) {
  305 + try {
  306 + return java.net.URLEncoder.encode(s == null ? "" : s, "UTF-8");
  307 + } catch (java.io.UnsupportedEncodingException e) {
  308 + return s;
  309 + }
  310 + }
  311 +
  312 + /**
  313 + * 上传文件到启效文件服务,返回 fileId。
  314 + * <p>
  315 + * 流程:checkFile(按 md5 检存)→ uploadChunk(multipart 单分片)→ mergeFileChunk。
  316 + */
  317 + public String uploadFile(String token, File file, String fileName) {
  318 + try {
  319 + String md5 = md5Hex(file);
  320 + String ext = (fileName != null && fileName.lastIndexOf('.') >= 0)
  321 + ? fileName.substring(fileName.lastIndexOf('.')) : "";
  322 + String checkUrl = baseUrl + "/qx-api/qgyun-service-fs-manager/file/checkFile"
  323 + + "?fileMd5=" + md5 + "&publicFile=false&fileExt=" + urlEncode(ext);
  324 + JSONObject check = JsonHttpUtils.getJson2Obj(checkUrl, JsonHttpUtils.token(token));
  325 + if (Boolean.TRUE.equals(check.getBoolean("success"))) {
  326 + String existId = check.getString("msg");
  327 + if (existId != null && !existId.isEmpty()) {
  328 + log.info("文件已存在, 复用 fileId={}", existId);
  329 + return existId;
  330 + }
  331 + }
  332 + JSONObject data = check.getJSONObject("data");
  333 + if (data == null) {
  334 + throw new IllegalStateException("checkFile 返回异常: " + check);
  335 + }
  336 + final String uploadUrl = data.getString("uploadUrl");
  337 + final String tk = data.getString("token");
  338 + final long size = file.length();
  339 +
  340 + RuntimeException last = null;
  341 + for (int attempt = 1; attempt <= 5; attempt++) {
  342 + try {
  343 + HttpPost post = new HttpPost(uploadUrl + "file/uploadChunk?__tk=" + tk);
  344 + post.setHeader("Authorization", token);
  345 + post.setHeader("User-Agent", JsonHttpUtils.USER_AGENT);
  346 + post.setEntity(MultipartEntityBuilder.create()
  347 + .addBinaryBody("file", file, ContentType.create("application/octet-stream"), fileName)
  348 + .addTextBody("name", fileName, ContentType.create("text/plain", StandardCharsets.UTF_8))
  349 + .addTextBody("fileMd5", md5, ContentType.create("text/plain", StandardCharsets.UTF_8))
  350 + .addTextBody("size", String.valueOf(size), ContentType.create("text/plain", StandardCharsets.UTF_8))
  351 + .addTextBody("chunkIndex", "0", ContentType.create("text/plain", StandardCharsets.UTF_8))
  352 + .addTextBody("chunkSize", "10485760", ContentType.create("text/plain", StandardCharsets.UTF_8))
  353 + .build());
  354 + String upResp = JsonHttpUtils.run(post);
  355 + JSONObject upObj = JsonHttpUtils.parse(upResp);
  356 + if (!Boolean.TRUE.equals(upObj.getBoolean("success"))) {
  357 + throw new IllegalStateException("上传分片失败: " + upResp);
  358 + }
  359 +
  360 + Map<String, Object> mergeBody = new HashMap<>();
  361 + mergeBody.put("fileName", fileName);
  362 + mergeBody.put("fileMd5", md5);
  363 + mergeBody.put("chunkCount", 1);
  364 + mergeBody.put("chunkSize", 10485760);
  365 + JSONObject merge = JsonHttpUtils.postJson2Obj(uploadUrl + "file/mergeFileChunk?__tk=" + tk,
  366 + JsonHttpUtils.token(token), mergeBody);
  367 + if (!Boolean.TRUE.equals(merge.getBoolean("success"))) {
  368 + throw new IllegalStateException("合并分片失败: " + merge);
  369 + }
  370 + String fileId = merge.getJSONObject("data").getString("fileId");
  371 + log.info("文件上传成功: {} -> fileId={}", fileName, fileId);
  372 + return fileId;
  373 + } catch (RuntimeException ex) {
  374 + long wait = 3000L * attempt;
  375 + log.warn("上传附件异常({}),{}ms 后重试(第{}次)", ex.getMessage(), wait, attempt);
  376 + try {
  377 + Thread.sleep(wait);
  378 + } catch (InterruptedException ignored) {
  379 + Thread.currentThread().interrupt();
  380 + }
  381 + last = ex;
  382 + }
  383 + }
  384 + throw last == null ? new IllegalStateException("上传文件失败") : last;
  385 + } catch (IllegalStateException e) {
  386 + throw e;
  387 + } catch (Exception e) {
  388 + throw new RuntimeException("上传文件失败: " + e.getMessage(), e);
  389 + }
  390 + }
  391 +
  392 + private static String md5Hex(File file) throws Exception {
  393 + MessageDigest md = MessageDigest.getInstance("MD5");
  394 + byte[] digest = md.digest(java.nio.file.Files.readAllBytes(file.toPath()));
  395 + StringBuilder sb = new StringBuilder();
  396 + for (byte b : digest) {
  397 + sb.append(String.format("%02x", b));
  398 + }
  399 + return sb.toString();
  400 + }
  401 +
  402 + /** 分页查询某表单数据,返回记录数组(含 id)。 */
  403 + public JSONArray pageQuery(String token, String appId, String pageCode) {
  404 + Map<String, Object> body = new HashMap<>();
  405 + body.put("pageNum", 1);
  406 + body.put("pageSize", 1000);
  407 + body.put("searchConditions", null);
  408 + JSONObject resp = JsonHttpUtils.postJson2Obj(
  409 + baseUrl + "/qx-api/qx-apaas-run/ar/" + appId + "/" + pageCode,
  410 + JsonHttpUtils.token(token), body);
  411 + JSONObject data = resp.getJSONObject("data");
  412 + return data == null ? new JSONArray() : data.getJSONArray("list");
  413 + }
  414 +
  415 + /** 分页拉取某表单全部数据(分页参数走 URL query),返回记录数组。 */
  416 + public JSONArray pageAll(String token, String appId, String pageCode) {
  417 + JSONArray all = new JSONArray();
  418 + int pageNum = 1;
  419 + int pageSize = 200;
  420 + while (true) {
  421 + String url = baseUrl + "/qx-api/qx-apaas-run/ar/" + appId + "/" + pageCode
  422 + + "?pageSize=" + pageSize + "&pageNum=" + pageNum;
  423 + JSONObject resp = JsonHttpUtils.postJson2Obj(url, JsonHttpUtils.token(token),
  424 + new HashMap<String, Object>());
  425 + if (!Boolean.TRUE.equals(resp.getBoolean("success"))) {
  426 + throw new IllegalStateException("分页查询失败(" + pageCode + "): " + resp.getString("msg")
  427 + + " [" + resp.getString("errCode") + "]");
  428 + }
  429 + JSONObject data = resp.getJSONObject("data");
  430 + JSONArray list = data == null ? null : data.getJSONArray("list");
  431 + if (list == null || list.isEmpty()) {
  432 + break;
  433 + }
  434 + all.addAll(list);
  435 + if (list.size() < pageSize) {
  436 + break;
  437 + }
  438 + pageNum++;
  439 + if (pageNum > 10000) {
  440 + break;
  441 + }
  442 + }
  443 + return all;
  444 + }
  445 +
  446 + /** 删除一条数据(单次删除接口,GET ?id=)。 */
  447 + public void deleteData(String token, String appId, String deleteCode, String dataId) {
  448 + String url = baseUrl + "/qx-api/qx-apaas-run/ar/" + appId + "/" + deleteCode + "?id=" + dataId;
  449 + JsonHttpUtils.get(url, JsonHttpUtils.token(token));
  450 + }
  451 +
  452 + private static Map<String, Object> buildComp(Field f) {
  453 + String compId = randomCompId();
  454 + String t = f.type;
  455 + Map<String, Object> props = new HashMap<>();
  456 + props.put("type", "string");
  457 + props.put("title", f.title);
  458 + props.put("compType", compType(t));
  459 + props.put("fieldKey", f.fieldKey);
  460 + props.put("descType", "icon");
  461 + props.put("propType", propType(t));
  462 + Map<String, Object> deco = new HashMap<>();
  463 + Map<String, Object> style = new HashMap<>();
  464 + style.put("width", "calc(100% - (var(--formGridColumnGap, 0px) / 2) * 0)");
  465 + deco.put("style", style);
  466 + props.put("decoratorProps", deco);
  467 + props.put("defaultConfig", new HashMap<>());
  468 + props.put("events", new ArrayList<>());
  469 + props.put("required", false);
  470 + if ("number".equals(t)) {
  471 + Map<String, Object> mpc = new HashMap<>();
  472 + mpc.put("unitPosition", "prefix");
  473 + mpc.put("isLimit", false);
  474 + mpc.put("isFormat", false);
  475 + props.put("modelPropCfg", mpc);
  476 + } else if ("text".equals(t)) {
  477 + // 文本字段默认限 50 字符,显式放开到 2000,避免长文本写入失败
  478 + Map<String, Object> mpc = new HashMap<>();
  479 + mpc.put("isLimit", true);
  480 + Map<String, Object> limit = new HashMap<>();
  481 + limit.put("min", 0);
  482 + limit.put("max", 2000);
  483 + mpc.put("limit", limit);
  484 + mpc.put("formatRules", new ArrayList<>());
  485 + props.put("modelPropCfg", mpc);
  486 + } else if ("date".equals(t)) {
  487 + Map<String, Object> mpc = new HashMap<>();
  488 + mpc.put("isLimit", false);
  489 + Map<String, Object> limit = new HashMap<>();
  490 + limit.put("max", "");
  491 + limit.put("min", "");
  492 + mpc.put("limit", limit);
  493 + mpc.put("timeLimit", false);
  494 + mpc.put("weekLimit", false);
  495 + mpc.put("minuteStep", 1);
  496 + mpc.put("secondStep", 1);
  497 + props.put("modelPropCfg", mpc);
  498 + props.put("format", "YYYY-MM-DD HH:mm");
  499 + props.put("showTime", false);
  500 + }
  501 + Map<String, Object> comp = new HashMap<>();
  502 + comp.put("compId", compId);
  503 + comp.put("compProps", props);
  504 + comp.put("compType", compType(t));
  505 + comp.put("pluginType", pluginType(t));
  506 + comp.put("decoratorCompType", "FormItem");
  507 + return comp;
  508 + }
  509 +
  510 + private static Map<String, Object> winCfg() {
  511 + Map<String, Object> win = new HashMap<>();
  512 + win.put("ADD_FORM", winForm("新增", "ADD_FORM"));
  513 + win.put("EDIT_FORM", winForm("编辑", "EDIT_FORM"));
  514 + Map<String, Object> view = new HashMap<>();
  515 + view.put("title", "查看");
  516 + view.put("code", "VIEW_FORM");
  517 + win.put("VIEW_FORM", view);
  518 + return win;
  519 + }
  520 +
  521 + private static Map<String, Object> winForm(String title, String code) {
  522 + Map<String, Object> wf = new HashMap<>();
  523 + wf.put("title", title);
  524 + wf.put("code", code);
  525 + List<Map<String, Object>> btns = new ArrayList<>();
  526 + Map<String, Object> cancel = new HashMap<>();
  527 + cancel.put("btnCode", "cancel");
  528 + cancel.put("position", "BR");
  529 + cancel.put("title", "取消");
  530 + btns.add(cancel);
  531 + Map<String, Object> save = new HashMap<>();
  532 + save.put("btnCode", "save");
  533 + save.put("position", "BR");
  534 + save.put("title", "保存");
  535 + btns.add(save);
  536 + wf.put("btns", btns);
  537 + return wf;
  538 + }
  539 +
  540 + private static final Map<String, String> COMP = new HashMap<>();
  541 + private static final Map<String, String> PROP = new HashMap<>();
  542 + private static final Map<String, String> PLUGIN = new HashMap<>();
  543 +
  544 + static {
  545 + COMP.put("text", "QxCompInput");
  546 + COMP.put("number", "QxCompInputNumber");
  547 + COMP.put("percent", "QxCompPercent");
  548 + COMP.put("switch", "QxCompSwitch");
  549 + COMP.put("date", "QxCompDatePicker");
  550 + PROP.put("text", "QxString");
  551 + PROP.put("number", "QxNumber");
  552 + PROP.put("percent", "QxPercent");
  553 + PROP.put("switch", "QxBoolean");
  554 + PROP.put("date", "QxDate");
  555 + PLUGIN.put("text", "Ge5BHkj7HRUDktxTHwm.8SEfp20rm8YjoveHwzZ");
  556 + PLUGIN.put("number", "OuiaGY2Ik57Op9UWJ2x.vgRH7o3nbajAN7zvPHP");
  557 + PLUGIN.put("percent", "6oU3NyeGbABi49acDSx.EU68OPxMm8EtonRCgra");
  558 + PLUGIN.put("switch", "Wvaw42w8NqIhAHM2oBU.rYFlWbFV2SvdqpSV6Cy");
  559 + PLUGIN.put("date", "HM01fexzlT73F6PKzhq.sdWqQBWPaItOAC31tCa");
  560 + }
  561 +
  562 + private static String compType(String t) {
  563 + return COMP.getOrDefault(t, "QxCompInput");
  564 + }
  565 +
  566 + private static String propType(String t) {
  567 + return PROP.getOrDefault(t, "QxString");
  568 + }
  569 +
  570 + private static String pluginType(String t) {
  571 + return PLUGIN.getOrDefault(t, "Ge5BHkj7HRUDktxTHwm.8SEfp20rm8YjoveHwzZ");
  572 + }
  573 +
  574 + private static String randomCompId() {
  575 + final String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  576 + StringBuilder sb = new StringBuilder();
  577 + java.util.concurrent.ThreadLocalRandom r = java.util.concurrent.ThreadLocalRandom.current();
  578 + for (int i = 0; i < 19; i++) {
  579 + sb.append(chars.charAt(r.nextInt(chars.length())));
  580 + }
  581 + return sb.toString();
  582 + }
  583 +
  584 + /** 表单字段定义:字段标题 + 语义 fieldKey + 启效类型(text/number/date/percent/switch)。 */
  585 + public static class Field {
  586 + private final String title;
  587 + private final String fieldKey;
  588 + private final String type;
  589 +
  590 + public Field(String title, String fieldKey, String type) {
  591 + this.title = title;
  592 + this.fieldKey = fieldKey;
  593 + this.type = type;
  594 + }
  595 +
  596 + public String getTitle() {
  597 + return title;
  598 + }
  599 +
  600 + public String getFieldKey() {
  601 + return fieldKey;
  602 + }
  603 +
  604 + public String getType() {
  605 + return type;
  606 + }
  607 + }
  608 +
  609 + public String getBaseUrl() {
  610 + return baseUrl;
  611 + }
  612 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.config;
  2 +
  3 +import org.springframework.boot.context.properties.ConfigurationProperties;
  4 +
  5 +import java.util.ArrayList;
  6 +import java.util.LinkedHashMap;
  7 +import java.util.List;
  8 +import java.util.Map;
  9 +
  10 +/**
  11 + * 表单结构/数据同步配置(简道云 -> 启效指定应用)。
  12 + * <p>
  13 + * 对应 application.yml / application.properties 的 jdy-form-sync 段。
  14 + * 用途:把简道云表单的字段结构镜像到启效某应用下(新建表单模型),
  15 + * 数据同步则只取当月数据、按天留存(当天已存在先删再同步)。
  16 + */
  17 +@ConfigurationProperties(prefix = "jdy-form-sync")
  18 +public class JdyFormSyncProperties {
  19 +
  20 + /** 简道云配置 */
  21 + private Jdy jdy = new Jdy();
  22 +
  23 + /** 启效(QX)目标配置 */
  24 + private Qx qx = new Qx();
  25 +
  26 + /** 可选:指定同步月份(yyyy-MM),用于补数/测试;留空=当前自然月 */
  27 + private String month;
  28 +
  29 + /** Excel 导出目录(相对 jar 运行目录),默认 ./export */
  30 + private String exportDir = "./export";
  31 +
  32 + /** 待同步的表单列表 */
  33 + private List<Form> forms = new ArrayList<>();
  34 +
  35 + public String getMonth() {
  36 + return month;
  37 + }
  38 +
  39 + public void setMonth(String month) {
  40 + this.month = month;
  41 + }
  42 +
  43 + public String getExportDir() {
  44 + return exportDir;
  45 + }
  46 +
  47 + public void setExportDir(String exportDir) {
  48 + this.exportDir = exportDir;
  49 + }
  50 +
  51 + public Jdy getJdy() {
  52 + return jdy;
  53 + }
  54 +
  55 + public void setJdy(Jdy jdy) {
  56 + this.jdy = jdy;
  57 + }
  58 +
  59 + public Qx getQx() {
  60 + return qx;
  61 + }
  62 +
  63 + public void setQx(Qx qx) {
  64 + this.qx = qx;
  65 + }
  66 +
  67 + public List<Form> getForms() {
  68 + return forms;
  69 + }
  70 +
  71 + public void setForms(List<Form> forms) {
  72 + this.forms = forms;
  73 + }
  74 +
  75 + /** 简道云侧配置。 */
  76 + public static class Jdy {
  77 + /** 简道云基础地址(长安:https://jdy.changan.com.cn/api) */
  78 + private String host = "https://jdy.changan.com.cn/api";
  79 + /** 简道云 API_KEY */
  80 + private String apiKey;
  81 +
  82 + public String getHost() {
  83 + return host;
  84 + }
  85 +
  86 + public void setHost(String host) {
  87 + this.host = host;
  88 + }
  89 +
  90 + public String getApiKey() {
  91 + return apiKey;
  92 + }
  93 +
  94 + public void setApiKey(String apiKey) {
  95 + this.apiKey = apiKey;
  96 + }
  97 + }
  98 +
  99 + /** 启效(QX)侧目标配置。 */
  100 + public static class Qx {
  101 + private String baseUrl;
  102 + private String username;
  103 + private String password;
  104 + private String corpCode = "default";
  105 + /** 目标应用 appId(表单建在该应用下) */
  106 + private String appId;
  107 + /** 目标应用名称(saveDesignObj 需要) */
  108 + private String appName;
  109 +
  110 + public String getBaseUrl() {
  111 + return baseUrl;
  112 + }
  113 +
  114 + public void setBaseUrl(String baseUrl) {
  115 + this.baseUrl = baseUrl;
  116 + }
  117 +
  118 + public String getUsername() {
  119 + return username;
  120 + }
  121 +
  122 + public void setUsername(String username) {
  123 + this.username = username;
  124 + }
  125 +
  126 + public String getPassword() {
  127 + return password;
  128 + }
  129 +
  130 + public void setPassword(String password) {
  131 + this.password = password;
  132 + }
  133 +
  134 + public String getCorpCode() {
  135 + return corpCode;
  136 + }
  137 +
  138 + public void setCorpCode(String corpCode) {
  139 + this.corpCode = corpCode;
  140 + }
  141 +
  142 + public String getAppId() {
  143 + return appId;
  144 + }
  145 +
  146 + public void setAppId(String appId) {
  147 + this.appId = appId;
  148 + }
  149 +
  150 + public String getAppName() {
  151 + return appName;
  152 + }
  153 +
  154 + public void setAppName(String appName) {
  155 + this.appName = appName;
  156 + }
  157 + }
  158 +
  159 + /** 单个待同步表单。 */
  160 + public static class Form {
  161 + /** 简道云应用 app_id */
  162 + private String appId;
  163 + /** 简道云表单 entry_id */
  164 + private String entryId;
  165 + /** 启效表单名(默认用简道云表单名;.properties 读为 ISO-8859-1,不建议在此写中文) */
  166 + private String name;
  167 + /** 启效表单 funCode(默认按名称语义生成) */
  168 + private String funCode;
  169 + /** 已有启效表单模型 id;填了则原地更新该模型(不新建) */
  170 + private String modelId;
  171 + /** 简道云日期字段 code(用于「当月」过滤),如 _widget_xxx */
  172 + private String dateField;
  173 + /** 是否只同步当月数据;false=同步全部数据 */
  174 + private boolean monthOnly = true;
  175 + /** 字段映射覆盖(简道云字段中文名 -> 语义 fieldKey) */
  176 + private Map<String, String> fieldMapping = new LinkedHashMap<>();
  177 +
  178 + public String getAppId() {
  179 + return appId;
  180 + }
  181 +
  182 + public void setAppId(String appId) {
  183 + this.appId = appId;
  184 + }
  185 +
  186 + public String getEntryId() {
  187 + return entryId;
  188 + }
  189 +
  190 + public void setEntryId(String entryId) {
  191 + this.entryId = entryId;
  192 + }
  193 +
  194 + public String getName() {
  195 + return name;
  196 + }
  197 +
  198 + public void setName(String name) {
  199 + this.name = name;
  200 + }
  201 +
  202 + public String getFunCode() {
  203 + return funCode;
  204 + }
  205 +
  206 + public void setFunCode(String funCode) {
  207 + this.funCode = funCode;
  208 + }
  209 +
  210 + public String getModelId() {
  211 + return modelId;
  212 + }
  213 +
  214 + public void setModelId(String modelId) {
  215 + this.modelId = modelId;
  216 + }
  217 +
  218 + public String getDateField() {
  219 + return dateField;
  220 + }
  221 +
  222 + public void setDateField(String dateField) {
  223 + this.dateField = dateField;
  224 + }
  225 +
  226 + public boolean isMonthOnly() {
  227 + return monthOnly;
  228 + }
  229 +
  230 + public void setMonthOnly(boolean monthOnly) {
  231 + this.monthOnly = monthOnly;
  232 + }
  233 +
  234 + public Map<String, String> getFieldMapping() {
  235 + return fieldMapping;
  236 + }
  237 +
  238 + public void setFieldMapping(Map<String, String> fieldMapping) {
  239 + this.fieldMapping = fieldMapping;
  240 + }
  241 + }
  242 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.config;
  2 +
  3 +import org.springframework.boot.context.properties.ConfigurationProperties;
  4 +
  5 +import java.util.ArrayList;
  6 +import java.util.List;
  7 +
  8 +/**
  9 + * 风险场景统计配置。
  10 + * <p>
  11 + * 对应 application.yml 的 jdy-risk 段:调用长安简道云开放 API,读取指定表单数据,
  12 + * 按预定义规则过滤,输出各场景命中计数(count_1/count_2/count_3)。
  13 + */
  14 +@ConfigurationProperties(prefix = "jdy-risk")
  15 +public class JdyRiskProperties {
  16 +
  17 + /** 简道云基础地址(独立域名/独享版 https://<域名>/api) */
  18 + private String host = "https://jdy.changan.com.cn/api";
  19 +
  20 + /** 简道云 API_KEY(Bearer token) */
  21 + private String apiKey;
  22 +
  23 + /** 启效(QX)写回配置(把统计结果更新到目标应用表单) */
  24 + private Qx qx = new Qx();
  25 +
  26 + /** 场景清单 */
  27 + private List<Scenario> scenarios = new ArrayList<>();
  28 +
  29 + public String getHost() {
  30 + return host;
  31 + }
  32 +
  33 + public void setHost(String host) {
  34 + this.host = host;
  35 + }
  36 +
  37 + public String getApiKey() {
  38 + return apiKey;
  39 + }
  40 +
  41 + public void setApiKey(String apiKey) {
  42 + this.apiKey = apiKey;
  43 + }
  44 +
  45 + public Qx getQx() {
  46 + return qx;
  47 + }
  48 +
  49 + public void setQx(Qx qx) {
  50 + this.qx = qx;
  51 + }
  52 +
  53 + public List<Scenario> getScenarios() {
  54 + return scenarios;
  55 + }
  56 +
  57 + public void setScenarios(List<Scenario> scenarios) {
  58 + this.scenarios = scenarios;
  59 + }
  60 +
  61 + /** 启效(QX)写回目标配置。 */
  62 + public static class Qx {
  63 + /** 启效环境地址,如 https://hfca.uat3.qgutech.com */
  64 + private String baseUrl;
  65 + private String username;
  66 + private String password;
  67 + private String corpCode = "default";
  68 + /** 目标应用 appId */
  69 + private String appId;
  70 + /** 按 syncCode 查询记录的分页接口 code(如 ilr3ne7bPage) */
  71 + private String queryCode;
  72 + /** 查询时 syncCode 对应的查询参数名(如 pcqqjoquery) */
  73 + private String syncQueryParam;
  74 + /** 更新接口 code(如 11a41Update) */
  75 + private String updateCode;
  76 + /** 写回数量的字段编号(如 chcbxt) */
  77 + private String countField;
  78 + /** 附件字段编号(如 hciwkz);配置了则把简道云数据 Excel 作为附件写入(关联ID字段自动取 {attachField}Id) */
  79 + private String attachField;
  80 +
  81 + public String getBaseUrl() {
  82 + return baseUrl;
  83 + }
  84 +
  85 + public void setBaseUrl(String baseUrl) {
  86 + this.baseUrl = baseUrl;
  87 + }
  88 +
  89 + public String getUsername() {
  90 + return username;
  91 + }
  92 +
  93 + public void setUsername(String username) {
  94 + this.username = username;
  95 + }
  96 +
  97 + public String getPassword() {
  98 + return password;
  99 + }
  100 +
  101 + public void setPassword(String password) {
  102 + this.password = password;
  103 + }
  104 +
  105 + public String getCorpCode() {
  106 + return corpCode;
  107 + }
  108 +
  109 + public void setCorpCode(String corpCode) {
  110 + this.corpCode = corpCode;
  111 + }
  112 +
  113 + public String getAppId() {
  114 + return appId;
  115 + }
  116 +
  117 + public void setAppId(String appId) {
  118 + this.appId = appId;
  119 + }
  120 +
  121 + public String getQueryCode() {
  122 + return queryCode;
  123 + }
  124 +
  125 + public void setQueryCode(String queryCode) {
  126 + this.queryCode = queryCode;
  127 + }
  128 +
  129 + public String getSyncQueryParam() {
  130 + return syncQueryParam;
  131 + }
  132 +
  133 + public void setSyncQueryParam(String syncQueryParam) {
  134 + this.syncQueryParam = syncQueryParam;
  135 + }
  136 +
  137 + public String getUpdateCode() {
  138 + return updateCode;
  139 + }
  140 +
  141 + public void setUpdateCode(String updateCode) {
  142 + this.updateCode = updateCode;
  143 + }
  144 +
  145 + public String getCountField() {
  146 + return countField;
  147 + }
  148 +
  149 + public void setCountField(String countField) {
  150 + this.countField = countField;
  151 + }
  152 +
  153 + public String getAttachField() {
  154 + return attachField;
  155 + }
  156 +
  157 + public void setAttachField(String attachField) {
  158 + this.attachField = attachField;
  159 + }
  160 + }
  161 +
  162 + /**
  163 + * 单个统计场景。
  164 + * <p>
  165 + * type 取值:
  166 + * <ul>
  167 + * <li>scenario1:全量,满足 总计&gt;=5 且 差值&gt;=1 且 flowState=进行中 的行数</li>
  168 + * <li>scenario2:当月,按供应商分组,SI问题数量/问题数量合计&gt;60% 且 问题数量合计&gt;=5 的供应商个数</li>
  169 + * <li>scenario3:当月,SI占比&gt;60% 且 总问题数&gt;=5 的行数(工程师个数)</li>
  170 + * </ul>
  171 + */
  172 + public static class Scenario {
  173 + /** 输出键,如 count_1 */
  174 + private String id;
  175 + /** 场景名称 */
  176 + private String name;
  177 + /** 场景类型:scenario1 / scenario2 / scenario3 */
  178 + private String type;
  179 + /** 简道云应用 app_id */
  180 + private String appId;
  181 + /** 简道云表单 entry_id */
  182 + private String entryId;
  183 + /** 日期字段(当月过滤用) */
  184 + private String dateField;
  185 + /** 总计/总问题数字段 */
  186 + private String totalField;
  187 + /** 差值字段(scenario1) */
  188 + private String diffField;
  189 + /** 分组字段(scenario2,供应商名称) */
  190 + private String groupField;
  191 + /** 责任判定字段(scenario2,取值 SI 表示供应商责任) */
  192 + private String siField;
  193 + /** 问题数量字段(scenario2) */
  194 + private String qtyField;
  195 + /** SI占比字段(scenario3,表单已算好) */
  196 + private String ratioField;
  197 + /** 进行中对应的 flowState 值(scenario1,默认 0) */
  198 + private int inProgressState = 0;
  199 + /** 是否仅统计当月数据 */
  200 + private boolean monthOnly = false;
  201 + /** 是否仅统计当季度数据(scenario4/5) */
  202 + private boolean quarterOnly = false;
  203 + /** 是否仅统计当前年份(1月1日至今,scenario6) */
  204 + private boolean yearOnly = false;
  205 + /** 是否仅统计上个月(scenario8/9) */
  206 + private boolean prevMonthOnly = false;
  207 + /** 「升级」字段 code(scenario4/5) */
  208 + private String upField;
  209 + /** 「升级」需排除的取值(scenario4/5,空白始终排除);如 [QR] */
  210 + private List<String> excludeValues = new ArrayList<>();
  211 + /** 分组命中阈值(scenario4/5/6:分组次数 >= minCount 才算命中) */
  212 + private int minCount = 0;
  213 + /** 比率字段 code(scenario8/9,如 是否开启CR) */
  214 + private String flagField;
  215 + /** 比率字段命中值(scenario8/9,如 否) */
  216 + private String flagValue;
  217 + /** 比率阈值(scenario8/9,默认 0.7) */
  218 + private double ratioThreshold = 0.7;
  219 + /** 条码字段 code(scenario7,主表) */
  220 + private String barcodeField;
  221 + /** 需要排除的条码来源表单(scenario7) */
  222 + private List<ExcludeForm> excludeForms = new ArrayList<>();
  223 + /** 超期天数(scenario7:入库时间距今超过该天数才保留,默认 180) */
  224 + private int maxAgeDays = 180;
  225 + /** 只取这些字段列(scenario7:主表只拉需要的列,减小传输;空=全字段) */
  226 + private List<String> fields = new ArrayList<>();
  227 + /** Excel 附加列取值的字段 code(为空时 scenario1 默认写「命中」) */
  228 + private String extraField;
  229 + /** 写回启效目标记录的 syncCode(如 SYNC0001) */
  230 + private String syncCode;
  231 +
  232 + public String getId() {
  233 + return id;
  234 + }
  235 +
  236 + public void setId(String id) {
  237 + this.id = id;
  238 + }
  239 +
  240 + public String getName() {
  241 + return name;
  242 + }
  243 +
  244 + public void setName(String name) {
  245 + this.name = name;
  246 + }
  247 +
  248 + public String getType() {
  249 + return type;
  250 + }
  251 +
  252 + public void setType(String type) {
  253 + this.type = type;
  254 + }
  255 +
  256 + public String getAppId() {
  257 + return appId;
  258 + }
  259 +
  260 + public void setAppId(String appId) {
  261 + this.appId = appId;
  262 + }
  263 +
  264 + public String getEntryId() {
  265 + return entryId;
  266 + }
  267 +
  268 + public void setEntryId(String entryId) {
  269 + this.entryId = entryId;
  270 + }
  271 +
  272 + public String getDateField() {
  273 + return dateField;
  274 + }
  275 +
  276 + public void setDateField(String dateField) {
  277 + this.dateField = dateField;
  278 + }
  279 +
  280 + public String getTotalField() {
  281 + return totalField;
  282 + }
  283 +
  284 + public void setTotalField(String totalField) {
  285 + this.totalField = totalField;
  286 + }
  287 +
  288 + public String getDiffField() {
  289 + return diffField;
  290 + }
  291 +
  292 + public void setDiffField(String diffField) {
  293 + this.diffField = diffField;
  294 + }
  295 +
  296 + public String getGroupField() {
  297 + return groupField;
  298 + }
  299 +
  300 + public void setGroupField(String groupField) {
  301 + this.groupField = groupField;
  302 + }
  303 +
  304 + public String getSiField() {
  305 + return siField;
  306 + }
  307 +
  308 + public void setSiField(String siField) {
  309 + this.siField = siField;
  310 + }
  311 +
  312 + public String getQtyField() {
  313 + return qtyField;
  314 + }
  315 +
  316 + public void setQtyField(String qtyField) {
  317 + this.qtyField = qtyField;
  318 + }
  319 +
  320 + public String getRatioField() {
  321 + return ratioField;
  322 + }
  323 +
  324 + public void setRatioField(String ratioField) {
  325 + this.ratioField = ratioField;
  326 + }
  327 +
  328 + public int getInProgressState() {
  329 + return inProgressState;
  330 + }
  331 +
  332 + public void setInProgressState(int inProgressState) {
  333 + this.inProgressState = inProgressState;
  334 + }
  335 +
  336 + public boolean isMonthOnly() {
  337 + return monthOnly;
  338 + }
  339 +
  340 + public void setMonthOnly(boolean monthOnly) {
  341 + this.monthOnly = monthOnly;
  342 + }
  343 +
  344 + public boolean isQuarterOnly() {
  345 + return quarterOnly;
  346 + }
  347 +
  348 + public void setQuarterOnly(boolean quarterOnly) {
  349 + this.quarterOnly = quarterOnly;
  350 + }
  351 +
  352 + public boolean isYearOnly() {
  353 + return yearOnly;
  354 + }
  355 +
  356 + public void setYearOnly(boolean yearOnly) {
  357 + this.yearOnly = yearOnly;
  358 + }
  359 +
  360 + public boolean isPrevMonthOnly() {
  361 + return prevMonthOnly;
  362 + }
  363 +
  364 + public void setPrevMonthOnly(boolean prevMonthOnly) {
  365 + this.prevMonthOnly = prevMonthOnly;
  366 + }
  367 +
  368 + public String getFlagField() {
  369 + return flagField;
  370 + }
  371 +
  372 + public void setFlagField(String flagField) {
  373 + this.flagField = flagField;
  374 + }
  375 +
  376 + public String getFlagValue() {
  377 + return flagValue;
  378 + }
  379 +
  380 + public void setFlagValue(String flagValue) {
  381 + this.flagValue = flagValue;
  382 + }
  383 +
  384 + public double getRatioThreshold() {
  385 + return ratioThreshold;
  386 + }
  387 +
  388 + public void setRatioThreshold(double ratioThreshold) {
  389 + this.ratioThreshold = ratioThreshold;
  390 + }
  391 +
  392 + public String getBarcodeField() {
  393 + return barcodeField;
  394 + }
  395 +
  396 + public void setBarcodeField(String barcodeField) {
  397 + this.barcodeField = barcodeField;
  398 + }
  399 +
  400 + public List<ExcludeForm> getExcludeForms() {
  401 + return excludeForms;
  402 + }
  403 +
  404 + public void setExcludeForms(List<ExcludeForm> excludeForms) {
  405 + this.excludeForms = excludeForms;
  406 + }
  407 +
  408 + public int getMaxAgeDays() {
  409 + return maxAgeDays;
  410 + }
  411 +
  412 + public void setMaxAgeDays(int maxAgeDays) {
  413 + this.maxAgeDays = maxAgeDays;
  414 + }
  415 +
  416 + public List<String> getFields() {
  417 + return fields;
  418 + }
  419 +
  420 + public void setFields(List<String> fields) {
  421 + this.fields = fields;
  422 + }
  423 +
  424 + public String getUpField() {
  425 + return upField;
  426 + }
  427 +
  428 + public void setUpField(String upField) {
  429 + this.upField = upField;
  430 + }
  431 +
  432 + public List<String> getExcludeValues() {
  433 + return excludeValues;
  434 + }
  435 +
  436 + public void setExcludeValues(List<String> excludeValues) {
  437 + this.excludeValues = excludeValues;
  438 + }
  439 +
  440 + public int getMinCount() {
  441 + return minCount;
  442 + }
  443 +
  444 + public void setMinCount(int minCount) {
  445 + this.minCount = minCount;
  446 + }
  447 +
  448 + public String getExtraField() {
  449 + return extraField;
  450 + }
  451 +
  452 + public void setExtraField(String extraField) {
  453 + this.extraField = extraField;
  454 + }
  455 +
  456 + public String getSyncCode() {
  457 + return syncCode;
  458 + }
  459 +
  460 + public void setSyncCode(String syncCode) {
  461 + this.syncCode = syncCode;
  462 + }
  463 + }
  464 +
  465 + /** scenario7:需要排除的条码来源表单。 */
  466 + public static class ExcludeForm {
  467 + /** 简道云表单 entry_id */
  468 + private String entryId;
  469 + /** 条码字段 code(取值与主表 barcode-field 匹配则排除主表该行) */
  470 + private String barcodeField;
  471 +
  472 + public String getEntryId() {
  473 + return entryId;
  474 + }
  475 +
  476 + public void setEntryId(String entryId) {
  477 + this.entryId = entryId;
  478 + }
  479 +
  480 + public String getBarcodeField() {
  481 + return barcodeField;
  482 + }
  483 +
  484 + public void setBarcodeField(String barcodeField) {
  485 + this.barcodeField = barcodeField;
  486 + }
  487 + }
  488 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.config;
  2 +
  3 +import org.springframework.boot.context.properties.EnableConfigurationProperties;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.scheduling.annotation.EnableScheduling;
  6 +
  7 +/**
  8 + * 简道云同步相关配置装配:启用定时任务与配置属性。
  9 + */
  10 +@Configuration
  11 +@EnableScheduling
  12 +@EnableConfigurationProperties({JdySyncProperties.class, JdyRiskProperties.class, JdyFormSyncProperties.class})
  13 +public class JdySyncConfig {
  14 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.config;
  2 +
  3 +import org.springframework.boot.context.properties.ConfigurationProperties;
  4 +
  5 +import java.util.ArrayList;
  6 +import java.util.LinkedHashMap;
  7 +import java.util.List;
  8 +import java.util.Map;
  9 +
  10 +/**
  11 + * 简道云 -> 启效 同步配置。
  12 + * <p>
  13 + * 对应 application.yml 的 jdy-sync 段:
  14 + * <pre>
  15 + * jdy-sync:
  16 + * enabled: true
  17 + * qx:
  18 + * base-url: https://dev3.qgutech.com
  19 + * username: xx
  20 + * password: xx
  21 + * corp-code: default
  22 + * apps:
  23 + * - name: 组织管理
  24 + * api-key: xxx
  25 + * qx-app-id: xxx # 不填则按 name 自动在启效建应用
  26 + * replace: true # 是=每天清空后重建镜像;否=追加
  27 + * field-mapping: {姓名: name, 年龄: age} # 可选覆盖内置字典
  28 + * form-names: [人员管理, 部门管理] # 可选,空=同步全部表单
  29 + * </pre>
  30 + */
  31 +@ConfigurationProperties(prefix = "jdy-sync")
  32 +public class JdySyncProperties {
  33 +
  34 + /** 是否启用同步任务 */
  35 + private boolean enabled = true;
  36 +
  37 + /** 简道云基础地址(公有云 https://api.jiandaoyun.com/api;独立域名 https://<域名>/api) */
  38 + private String host = "https://api.jiandaoyun.com/api";
  39 +
  40 + /** 启效(QX)配置 */
  41 + private Qx qx = new Qx();
  42 +
  43 + public String getHost() {
  44 + return host;
  45 + }
  46 +
  47 + public void setHost(String host) {
  48 + this.host = host;
  49 + }
  50 +
  51 + /** 待同步的应用列表 */
  52 + private List<App> apps = new ArrayList<>();
  53 +
  54 + /** 每日执行 cron,默认每天 02:00 */
  55 + private String cron = "0 0 2 * * ?";
  56 +
  57 + public boolean isEnabled() {
  58 + return enabled;
  59 + }
  60 +
  61 + public void setEnabled(boolean enabled) {
  62 + this.enabled = enabled;
  63 + }
  64 +
  65 + public Qx getQx() {
  66 + return qx;
  67 + }
  68 +
  69 + public void setQx(Qx qx) {
  70 + this.qx = qx;
  71 + }
  72 +
  73 + public List<App> getApps() {
  74 + return apps;
  75 + }
  76 +
  77 + public void setApps(List<App> apps) {
  78 + this.apps = apps;
  79 + }
  80 +
  81 + public String getCron() {
  82 + return cron;
  83 + }
  84 +
  85 + public void setCron(String cron) {
  86 + this.cron = cron;
  87 + }
  88 +
  89 + public static class Qx {
  90 + private String baseUrl;
  91 + private String username;
  92 + private String password;
  93 + private String corpCode = "default";
  94 +
  95 + public String getBaseUrl() {
  96 + return baseUrl;
  97 + }
  98 +
  99 + public void setBaseUrl(String baseUrl) {
  100 + this.baseUrl = baseUrl;
  101 + }
  102 +
  103 + public String getUsername() {
  104 + return username;
  105 + }
  106 +
  107 + public void setUsername(String username) {
  108 + this.username = username;
  109 + }
  110 +
  111 + public String getPassword() {
  112 + return password;
  113 + }
  114 +
  115 + public void setPassword(String password) {
  116 + this.password = password;
  117 + }
  118 +
  119 + public String getCorpCode() {
  120 + return corpCode;
  121 + }
  122 +
  123 + public void setCorpCode(String corpCode) {
  124 + this.corpCode = corpCode;
  125 + }
  126 + }
  127 +
  128 + public static class App {
  129 + /** 简道云应用名 */
  130 + private String name;
  131 + /** 简道云 API_KEY(Bearer token) */
  132 + private String apiKey;
  133 + /** 简道云应用 app_id;不填则按 name 调用 /v5/app/list 反查(需应用列表权限) */
  134 + private String appId;
  135 + /** 可选:仅同步这些简道云表单 entry_id;空=同步该应用下全部表单 */
  136 + private List<String> entryIds = new ArrayList<>();
  137 + /** 目标启效应用 id;不填则按 name 自动创建 */
  138 + private String qxAppId;
  139 + /** 是=每天清空目标表单数据后重建镜像;否=追加 */
  140 + private boolean replace = true;
  141 + /** 可选字段映射(中文名 -> 语义 fieldKey),覆盖内置字典 */
  142 + private Map<String, String> fieldMapping = new LinkedHashMap<>();
  143 + /** 可选:仅同步这些表单(简道云表单名);空=全部 */
  144 + private List<String> formNames = new ArrayList<>();
  145 +
  146 + public String getName() {
  147 + return name;
  148 + }
  149 +
  150 + public void setName(String name) {
  151 + this.name = name;
  152 + }
  153 +
  154 + public String getApiKey() {
  155 + return apiKey;
  156 + }
  157 +
  158 + public void setApiKey(String apiKey) {
  159 + this.apiKey = apiKey;
  160 + }
  161 +
  162 + public String getAppId() {
  163 + return appId;
  164 + }
  165 +
  166 + public void setAppId(String appId) {
  167 + this.appId = appId;
  168 + }
  169 +
  170 + public List<String> getEntryIds() {
  171 + return entryIds;
  172 + }
  173 +
  174 + public void setEntryIds(List<String> entryIds) {
  175 + this.entryIds = entryIds;
  176 + }
  177 +
  178 + public String getQxAppId() {
  179 + return qxAppId;
  180 + }
  181 +
  182 + public void setQxAppId(String qxAppId) {
  183 + this.qxAppId = qxAppId;
  184 + }
  185 +
  186 + public boolean isReplace() {
  187 + return replace;
  188 + }
  189 +
  190 + public void setReplace(boolean replace) {
  191 + this.replace = replace;
  192 + }
  193 +
  194 + public Map<String, String> getFieldMapping() {
  195 + return fieldMapping;
  196 + }
  197 +
  198 + public void setFieldMapping(Map<String, String> fieldMapping) {
  199 + this.fieldMapping = fieldMapping;
  200 + }
  201 +
  202 + public List<String> getFormNames() {
  203 + return formNames;
  204 + }
  205 +
  206 + public void setFormNames(List<String> formNames) {
  207 + this.formNames = formNames;
  208 + }
  209 + }
  210 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.controller;
  2 +
  3 +import com.qgutech.qgyun.apaas.sync.jdy.service.JdyExcelExportService;
  4 +import com.qgutech.qgyun.apaas.sync.jdy.service.JdyFormSyncService;
  5 +import lombok.extern.slf4j.Slf4j;
  6 +import org.springframework.web.bind.annotation.PostMapping;
  7 +import org.springframework.web.bind.annotation.RequestMapping;
  8 +import org.springframework.web.bind.annotation.RequestParam;
  9 +import org.springframework.web.bind.annotation.RestController;
  10 +
  11 +import javax.annotation.Resource;
  12 +import java.util.List;
  13 +
  14 +/**
  15 + * 简道云表单同步接口(结构 / 数据 / 导出)。
  16 + */
  17 +@Slf4j
  18 +@RestController
  19 +@RequestMapping("/api/jdy/form")
  20 +public class JdyFormController {
  21 +
  22 + @Resource
  23 + private JdyFormSyncService formSyncService;
  24 +
  25 + @Resource
  26 + private JdyExcelExportService excelExportService;
  27 +
  28 + /** 同步表单结构(字段),在目标应用下新建表单模型。 */
  29 + @PostMapping("/sync-structure")
  30 + public List<String> syncStructure() {
  31 + return formSyncService.syncStructures();
  32 + }
  33 +
  34 + /** 同步表单数据:当月数据、当天已存在先删当天再同步。 */
  35 + @PostMapping("/sync-data")
  36 + public List<String> syncData() {
  37 + return formSyncService.syncData();
  38 + }
  39 +
  40 + /** 把简道云表单数据导出为 Excel,保存到导出目录,返回文件路径。 */
  41 + @PostMapping("/export-excel")
  42 + public List<String> exportExcel() {
  43 + return excelExportService.exportAll();
  44 + }
  45 +
  46 + /**
  47 + * 临时导出单个简道云表单(供演示/手动上传)。
  48 + * 例:/api/jdy/form/export-excel-form?appId=xxx&entryId=xxx&month=2026-09
  49 + */
  50 + @PostMapping("/export-excel-form")
  51 + public String exportExcelForm(@RequestParam(required = false) String appId,
  52 + @RequestParam(required = false) String entryId,
  53 + @RequestParam(required = false) String name,
  54 + @RequestParam(required = false) String month,
  55 + @RequestParam(required = false) String dateField) {
  56 + return excelExportService.exportOne(appId, entryId, name, month, dateField);
  57 + }
  58 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.controller;
  2 +
  3 +import com.qgutech.qgyun.apaas.sync.jdy.service.JdyRiskStatisticsService;
  4 +import lombok.extern.slf4j.Slf4j;
  5 +import org.springframework.web.bind.annotation.GetMapping;
  6 +import org.springframework.web.bind.annotation.PathVariable;
  7 +import org.springframework.web.bind.annotation.PostMapping;
  8 +import org.springframework.web.bind.annotation.RequestMapping;
  9 +import org.springframework.web.bind.annotation.RestController;
  10 +
  11 +import javax.annotation.Resource;
  12 +import java.util.Map;
  13 +
  14 +/**
  15 + * 风险场景统计接口。
  16 + * <p>
  17 + * 全部场景:`/api/jdy/risk/count`、`/api/jdy/risk/sync`
  18 + * 单个场景:`/api/jdy/risk/count/{场景id或syncCode}`、`/api/jdy/risk/sync/{场景id或syncCode}`
  19 + * (场景 id 如 count_1;syncCode 如 SYNC0001。浏览器直接输地址即可,GET/POST 均可)
  20 + */
  21 +@Slf4j
  22 +@RestController
  23 +@RequestMapping("/api/jdy/risk")
  24 +public class JdyRiskController {
  25 +
  26 + @Resource
  27 + private JdyRiskStatisticsService riskService;
  28 +
  29 + /** 统计全部场景。 */
  30 + @PostMapping("/count")
  31 + public Map<String, Object> count() {
  32 + return riskService.countAll();
  33 + }
  34 +
  35 + @GetMapping("/count")
  36 + public Map<String, Object> countGet() {
  37 + return riskService.countAll();
  38 + }
  39 +
  40 + /** 统计全部场景,并按 syncCode 更新到启效目标表单。 */
  41 + @PostMapping("/sync")
  42 + public Map<String, Object> sync() {
  43 + return riskService.countAndSync();
  44 + }
  45 +
  46 + @GetMapping("/sync")
  47 + public Map<String, Object> syncGet() {
  48 + return riskService.countAndSync();
  49 + }
  50 +
  51 + /** 只统计单个场景(id 或 syncCode)。 */
  52 + @PostMapping("/count/{scenarioId}")
  53 + public Map<String, Object> countOne(@PathVariable String scenarioId) {
  54 + return riskService.countOne(scenarioId);
  55 + }
  56 +
  57 + @GetMapping("/count/{scenarioId}")
  58 + public Map<String, Object> countOneGet(@PathVariable String scenarioId) {
  59 + return riskService.countOne(scenarioId);
  60 + }
  61 +
  62 + /** 只同步单个场景(id 或 syncCode):统计 + 写回 chcbxt + 上传 Excel 附件。 */
  63 + @PostMapping("/sync/{scenarioId}")
  64 + public Map<String, Object> syncOne(@PathVariable String scenarioId) {
  65 + return riskService.countAndSyncOne(scenarioId);
  66 + }
  67 +
  68 + @GetMapping("/sync/{scenarioId}")
  69 + public Map<String, Object> syncOneGet(@PathVariable String scenarioId) {
  70 + return riskService.countAndSyncOne(scenarioId);
  71 + }
  72 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.controller;
  2 +
  3 +import com.qgutech.qgyun.apaas.sync.jdy.config.JdySyncProperties;
  4 +import com.qgutech.qgyun.apaas.sync.jdy.service.JdyToQxSyncService;
  5 +import lombok.extern.slf4j.Slf4j;
  6 +import org.springframework.web.bind.annotation.PathVariable;
  7 +import org.springframework.web.bind.annotation.PostMapping;
  8 +import org.springframework.web.bind.annotation.RequestMapping;
  9 +import org.springframework.web.bind.annotation.RestController;
  10 +
  11 +import javax.annotation.Resource;
  12 +import java.util.Collections;
  13 +import java.util.List;
  14 +
  15 +/**
  16 + * 简道云同步手动触发接口(方便联调/补跑)。
  17 + */
  18 +@Slf4j
  19 +@RestController
  20 +@RequestMapping("/api/jdy/sync")
  21 +public class JdySyncController {
  22 +
  23 + @Resource
  24 + private JdyToQxSyncService syncService;
  25 +
  26 + @Resource
  27 + private JdySyncProperties props;
  28 +
  29 + /** 同步全部配置应用。 */
  30 + @PostMapping("/all")
  31 + public List<String> syncAll() {
  32 + return syncService.syncAll();
  33 + }
  34 +
  35 + /** 按简道云应用名同步单个应用。 */
  36 + @PostMapping("/{appName}")
  37 + public List<String> syncOne(@PathVariable String appName) {
  38 + for (JdySyncProperties.App app : props.getApps()) {
  39 + if (appName.equals(app.getName())) {
  40 + return Collections.singletonList(syncService.syncApp(app));
  41 + }
  42 + }
  43 + return Collections.singletonList("NOT_FOUND: " + appName);
  44 + }
  45 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.service;
  2 +
  3 +import java.util.Arrays;
  4 +import java.util.HashMap;
  5 +import java.util.HashSet;
  6 +import java.util.List;
  7 +import java.util.Map;
  8 +import java.util.Set;
  9 +import java.util.concurrent.ThreadLocalRandom;
  10 +
  11 +/**
  12 + * 中文字段/表单名 -> 语义英文 code 的映射与类型转换(遵循 qx-design-app 命名规范)。
  13 + */
  14 +public final class FieldMapping {
  15 +
  16 + /** 常见中文字段名 -> 语义 fieldKey */
  17 + private static final Map<String, String> FIELD_DICT = new HashMap<>();
  18 + /** 常见中文字单名 -> 语义 funCode */
  19 + private static final Map<String, String> FORM_DICT = new HashMap<>();
  20 + /** 简道云类型 -> 启效字段类型 */
  21 + private static final Map<String, String> TYPE_MAP = new HashMap<>();
  22 + /** 日期类简道云类型(写入需毫秒时间戳) */
  23 + private static final List<String> DATE_JDY_TYPES = Arrays.asList("datetime", "date");
  24 +
  25 + static {
  26 + putField("姓名", "name"); putField("名称", "name"); putField("用户名", "username"); putField("账号", "account");
  27 + putField("年龄", "age"); putField("性别", "gender"); putField("手机号", "phone"); putField("电话", "phone");
  28 + putField("邮箱", "email"); putField("地址", "address"); putField("备注", "remark"); putField("说明", "remark");
  29 + putField("描述", "description"); putField("编号", "code"); putField("编码", "code"); putField("部门编号", "deptNo");
  30 + putField("部门名称", "deptName"); putField("部门", "department"); putField("职位", "position");
  31 + putField("数量", "quantity"); putField("金额", "amount"); putField("价格", "price"); putField("单价", "price");
  32 + putField("日期", "date"); putField("创建日期", "createDate"); putField("出生日期", "birthDate");
  33 + putField("开始日期", "startDate"); putField("结束日期", "endDate"); putField("时间", "time");
  34 + putField("类型", "type"); putField("状态", "status"); putField("是否启用", "enabled");
  35 + putField("标题", "title"); putField("内容", "content"); putField("负责人", "owner"); putField("经手人", "handler");
  36 +
  37 + // 3mis 争议件大于确定件(合肥长安_技术处)
  38 + putField("日期时间", "dateTime"); putField("在用期", "inUsePeriod"); putField("车型", "vehicleModel");
  39 + putField("旧件名称", "oldPartName"); putField("牵头人", "leadUser"); putField("非零件问题", "nonPartIssue");
  40 + putField("确定故障件", "confirmedFaultPart"); putField("争议故障件", "disputedFaultPart");
  41 + putField("差值(争议-确定≥1)", "diffValue"); putField("总计≥5", "totalCount"); putField("情况说明", "description");
  42 +
  43 + putForm("人员管理", "personnel"); putForm("部门管理", "department"); putForm("员工管理", "employee");
  44 + putForm("客户管理", "customer"); putForm("订单管理", "order"); putForm("产品管理", "product");
  45 + putForm("供应商管理", "supplier"); putForm("设备管理", "equipment"); putForm("库存管理", "inventory");
  46 + putForm("采购管理", "purchase"); putForm("合同管理", "contract"); putForm("项目管理", "project");
  47 + putForm("用户管理", "user"); putForm("角色管理", "role"); putForm("菜单管理", "menu");
  48 +
  49 + TYPE_MAP.put("text", "text"); TYPE_MAP.put("number", "number"); TYPE_MAP.put("datetime", "date");
  50 + TYPE_MAP.put("date", "date"); TYPE_MAP.put("textarea", "text"); TYPE_MAP.put("percent", "percent");
  51 + TYPE_MAP.put("switch", "switch");
  52 + }
  53 +
  54 + private static void putField(String zh, String en) {
  55 + FIELD_DICT.putIfAbsent(zh, en);
  56 + }
  57 +
  58 + private static void putForm(String zh, String en) {
  59 + FORM_DICT.putIfAbsent(zh, en);
  60 + }
  61 +
  62 + private FieldMapping() {
  63 + }
  64 +
  65 + /** 生成字段 fieldKey;未识别中文则随机 6 位并在日志标记。 */
  66 + public static String genFieldKey(String label, Map<String, String> override, Set<String> used) {
  67 + String fk = null;
  68 + if (override != null) {
  69 + fk = override.get(label);
  70 + }
  71 + if (fk == null || fk.isEmpty()) {
  72 + fk = FIELD_DICT.get(label);
  73 + }
  74 + if (fk == null || fk.isEmpty()) {
  75 + fk = randomLower(6);
  76 + }
  77 + while (!used.add(fk)) {
  78 + fk = fk + "x";
  79 + }
  80 + return fk;
  81 + }
  82 +
  83 + /** 生成表单 funCode;未识别中文表单名则随机 6 位。 */
  84 + public static String genFunCode(String formName) {
  85 + String fc = FORM_DICT.get(formName);
  86 + if (fc == null || fc.isEmpty()) {
  87 + fc = randomLower(6);
  88 + }
  89 + return fc;
  90 + }
  91 +
  92 + /** 简道云字段类型 -> 启效字段类型(未识别默认 text)。 */
  93 + public static String mapType(String jdyType) {
  94 + if (jdyType == null) {
  95 + return "text";
  96 + }
  97 + return TYPE_MAP.getOrDefault(jdyType.toLowerCase(), "text");
  98 + }
  99 +
  100 + public static boolean isDateJdy(String jdyType) {
  101 + return jdyType != null && DATE_JDY_TYPES.contains(jdyType.toLowerCase());
  102 + }
  103 +
  104 + public static String randomLower(int len) {
  105 + String chars = "abcdefghijklmnopqrstuvwxyz";
  106 + StringBuilder sb = new StringBuilder();
  107 + ThreadLocalRandom r = ThreadLocalRandom.current();
  108 + for (int i = 0; i < len; i++) {
  109 + sb.append(chars.charAt(r.nextInt(chars.length())));
  110 + }
  111 + return sb.toString();
  112 + }
  113 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.service;
  2 +
  3 +import com.alibaba.fastjson.JSONArray;
  4 +import com.alibaba.fastjson.JSONObject;
  5 +import com.qgutech.qgyun.apaas.sync.jdy.api.JiandaoYunApi;
  6 +import com.qgutech.qgyun.apaas.sync.jdy.config.JdyFormSyncProperties;
  7 +import lombok.extern.slf4j.Slf4j;
  8 +import org.apache.poi.ss.usermodel.Cell;
  9 +import org.apache.poi.ss.usermodel.CellStyle;
  10 +import org.apache.poi.ss.usermodel.Font;
  11 +import org.apache.poi.ss.usermodel.Row;
  12 +import org.apache.poi.ss.usermodel.Sheet;
  13 +import org.apache.poi.ss.usermodel.Workbook;
  14 +import org.apache.poi.xssf.usermodel.XSSFWorkbook;
  15 +import org.springframework.stereotype.Service;
  16 +
  17 +import java.io.File;
  18 +import java.io.FileOutputStream;
  19 +import java.io.OutputStream;
  20 +import java.time.Instant;
  21 +import java.time.ZoneId;
  22 +import java.time.ZonedDateTime;
  23 +import java.time.format.DateTimeFormatter;
  24 +import java.util.ArrayList;
  25 +import java.util.List;
  26 +
  27 +/**
  28 + * 把简道云表单数据导出为 Excel(.xlsx),保存到配置的导出目录。
  29 + * <p>文件名统一为 yyyyMMddHHmmss.xlsx。
  30 + */
  31 +@Slf4j
  32 +@Service
  33 +public class JdyExcelExportService {
  34 +
  35 + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
  36 + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
  37 +
  38 + private final JdyFormSyncProperties props;
  39 +
  40 + public JdyExcelExportService(JdyFormSyncProperties props) {
  41 + this.props = props;
  42 + }
  43 +
  44 + public File exportDir() {
  45 + File dir = new File(props.getExportDir());
  46 + if (!dir.exists() && !dir.mkdirs()) {
  47 + log.warn("导出目录创建失败: {}", dir.getAbsolutePath());
  48 + }
  49 + return dir;
  50 + }
  51 +
  52 + public static String nowFileName() {
  53 + return ZonedDateTime.now(ZONE).format(TS) + ".xlsx";
  54 + }
  55 +
  56 + /** 导出所有配置表单的数据到 Excel,返回生成的文件路径列表。 */
  57 + public List<String> exportAll() {
  58 + List<String> files = new ArrayList<>();
  59 + JiandaoYunApi jdy = new JiandaoYunApi(props.getJdy().getHost());
  60 + File dir = exportDir();
  61 + String ts = ZonedDateTime.now(ZONE).format(TS);
  62 + for (JdyFormSyncProperties.Form f : props.getForms()) {
  63 + try {
  64 + JSONArray widgets = jdy.listWidgets(props.getJdy().getApiKey(), f.getAppId(), f.getEntryId());
  65 + JSONArray data = jdy.listAllData(props.getJdy().getApiKey(), f.getAppId(), f.getEntryId());
  66 + File outFile = new File(dir, ts + ".xlsx");
  67 + writeWorkbook(widgets, data, null, null, outFile);
  68 + log.info("导出 Excel: {} ({} 条)", outFile.getAbsolutePath(), data.size());
  69 + files.add(outFile.getAbsolutePath());
  70 + } catch (Exception e) {
  71 + log.error("导出 Excel 失败: {}", f.getEntryId(), e);
  72 + files.add("FAIL " + f.getEntryId() + " : " + e.getMessage());
  73 + }
  74 + }
  75 + return files;
  76 + }
  77 +
  78 + /**
  79 + * 临时导出单个简道云表单(供演示/手动上传用)。
  80 + *
  81 + * @param month 可选,yyyy-MM(按日期字段过滤当月)
  82 + * @param dateField 可选,日期字段 code;不传则自动取第一个日期型字段
  83 + */
  84 + public String exportOne(String appId, String entryId, String name, String month, String dateField) {
  85 + try {
  86 + JiandaoYunApi jdy = new JiandaoYunApi(props.getJdy().getHost());
  87 + File dir = exportDir();
  88 + JSONArray widgets = jdy.listWidgets(props.getJdy().getApiKey(), appId, entryId);
  89 + JSONArray data = jdy.listAllData(props.getJdy().getApiKey(), appId, entryId);
  90 + if (month != null && !month.isEmpty()) {
  91 + String df = (dateField != null && !dateField.isEmpty()) ? dateField : firstDateField(widgets);
  92 + data = filterByMonth(data, df, month);
  93 + }
  94 + File outFile = new File(dir, nowFileName());
  95 + writeWorkbook(widgets, data, null, null, outFile);
  96 + log.info("导出 Excel: {} ({} 条, 表单={})", outFile.getAbsolutePath(), data.size(), name);
  97 + return outFile.getAbsolutePath();
  98 + } catch (Exception e) {
  99 + log.error("导出 Excel 失败: {}", entryId, e);
  100 + return "FAIL " + entryId + " : " + e.getMessage();
  101 + }
  102 + }
  103 +
  104 + /**
  105 + * 导出指定行到 Excel(供风险场景附件用),可在最右增加一列。
  106 + *
  107 + * @param extraHeader 附加列标题(为 null 则不附加)
  108 + * @param extraValues 附加列每行的值(与 rows 一一对应)
  109 + * @return 生成的文件路径
  110 + */
  111 + public String exportRows(JSONArray widgets, JSONArray rows, String extraHeader,
  112 + List<String> extraValues, String fileName) {
  113 + try {
  114 + File dir = exportDir();
  115 + File outFile = new File(dir, fileName);
  116 + writeWorkbook(widgets, rows, extraHeader, extraValues, outFile);
  117 + log.info("导出 Excel: {} ({} 条)", outFile.getAbsolutePath(), rows.size());
  118 + return outFile.getAbsolutePath();
  119 + } catch (Exception e) {
  120 + log.error("导出 Excel 失败: {}", fileName, e);
  121 + return "FAIL " + fileName + " : " + e.getMessage();
  122 + }
  123 + }
  124 +
  125 + private void writeWorkbook(JSONArray widgets, JSONArray data, String extraHeader,
  126 + List<String> extraValues, File outFile) throws Exception {
  127 + try (Workbook wb = new XSSFWorkbook()) {
  128 + Sheet sheet = wb.createSheet("数据");
  129 + CellStyle headerStyle = wb.createCellStyle();
  130 + Font bold = wb.createFont();
  131 + bold.setBold(true);
  132 + headerStyle.setFont(bold);
  133 +
  134 + Row header = sheet.createRow(0);
  135 + for (int c = 0; c < widgets.size(); c++) {
  136 + JSONObject w = widgets.getJSONObject(c);
  137 + String label = w.getString("label");
  138 + if (label == null || label.isEmpty()) {
  139 + label = w.getString("widgetName");
  140 + }
  141 + Cell cell = header.createCell(c);
  142 + cell.setCellValue(label);
  143 + cell.setCellStyle(headerStyle);
  144 + }
  145 + boolean hasExtra = extraHeader != null && !extraHeader.isEmpty();
  146 + if (hasExtra) {
  147 + Cell cell = header.createCell(widgets.size());
  148 + cell.setCellValue(extraHeader);
  149 + cell.setCellStyle(headerStyle);
  150 + }
  151 +
  152 + for (int r = 0; r < data.size(); r++) {
  153 + JSONObject rec = data.getJSONObject(r);
  154 + Row row = sheet.createRow(r + 1);
  155 + for (int c = 0; c < widgets.size(); c++) {
  156 + String code = widgets.getJSONObject(c).getString("name");
  157 + row.createCell(c).setCellValue(formatValue(rec.get(code)));
  158 + }
  159 + if (hasExtra) {
  160 + String v = (extraValues != null && r < extraValues.size() && extraValues.get(r) != null)
  161 + ? extraValues.get(r) : "";
  162 + row.createCell(widgets.size()).setCellValue(v);
  163 + }
  164 + }
  165 + for (int c = 0; c <= widgets.size(); c++) {
  166 + sheet.setColumnWidth(c, 20 * 256);
  167 + }
  168 + try (OutputStream os = new FileOutputStream(outFile)) {
  169 + wb.write(os);
  170 + }
  171 + }
  172 + }
  173 +
  174 + private JSONArray filterByMonth(JSONArray data, String dateField, String month) {
  175 + JSONArray out = new JSONArray();
  176 + for (int i = 0; i < data.size(); i++) {
  177 + JSONObject rec = data.getJSONObject(i);
  178 + String iso = rec.getString(dateField);
  179 + if (iso != null && iso.length() >= 7) {
  180 + String m;
  181 + try {
  182 + m = Instant.parse(iso).atZone(ZONE).format(DateTimeFormatter.ofPattern("yyyy-MM"));
  183 + } catch (Exception e) {
  184 + m = iso.substring(0, 7);
  185 + }
  186 + if (month.equals(m)) {
  187 + out.add(rec);
  188 + }
  189 + }
  190 + }
  191 + return out;
  192 + }
  193 +
  194 + private String firstDateField(JSONArray widgets) {
  195 + for (int i = 0; i < widgets.size(); i++) {
  196 + JSONObject w = widgets.getJSONObject(i);
  197 + String t = w.getString("type");
  198 + if ("datetime".equalsIgnoreCase(t) || "date".equalsIgnoreCase(t)) {
  199 + return w.getString("name");
  200 + }
  201 + }
  202 + return null;
  203 + }
  204 +
  205 + /** 值格式化:时间->北京时间字符串;用户对象->name;数组->逗号连接。 */
  206 + private String formatValue(Object v) {
  207 + if (v == null) {
  208 + return "";
  209 + }
  210 + if (v instanceof JSONObject) {
  211 + String n = ((JSONObject) v).getString("name");
  212 + return n != null ? n : ((JSONObject) v).toJSONString();
  213 + }
  214 + if (v instanceof JSONArray) {
  215 + JSONArray arr = (JSONArray) v;
  216 + StringBuilder sb = new StringBuilder();
  217 + for (int i = 0; i < arr.size(); i++) {
  218 + Object o = arr.get(i);
  219 + String s;
  220 + if (o instanceof JSONObject) {
  221 + String n = ((JSONObject) o).getString("name");
  222 + s = n != null ? n : ((JSONObject) o).toJSONString();
  223 + } else {
  224 + s = String.valueOf(o);
  225 + }
  226 + if (sb.length() > 0) {
  227 + sb.append(",");
  228 + }
  229 + sb.append(s);
  230 + }
  231 + return sb.toString();
  232 + }
  233 + String s = String.valueOf(v);
  234 + if (s.matches("\\d{4}-\\d{2}-\\d{2}T.*")) {
  235 + try {
  236 + return Instant.parse(s).atZone(ZONE).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
  237 + } catch (Exception ignored) {
  238 + return s;
  239 + }
  240 + }
  241 + return s;
  242 + }
  243 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.service;
  2 +
  3 +import com.alibaba.fastjson.JSONArray;
  4 +import com.alibaba.fastjson.JSONObject;
  5 +import com.qgutech.qgyun.apaas.sync.jdy.api.JiandaoYunApi;
  6 +import com.qgutech.qgyun.apaas.sync.jdy.api.QxApi;
  7 +import com.qgutech.qgyun.apaas.sync.jdy.config.JdyFormSyncProperties;
  8 +import lombok.extern.slf4j.Slf4j;
  9 +import org.springframework.stereotype.Service;
  10 +
  11 +import java.time.Instant;
  12 +import java.time.ZoneId;
  13 +import java.time.ZonedDateTime;
  14 +import java.time.format.DateTimeFormatter;
  15 +import java.util.ArrayList;
  16 +import java.util.HashSet;
  17 +import java.util.LinkedHashMap;
  18 +import java.util.LinkedHashSet;
  19 +import java.util.List;
  20 +import java.util.Map;
  21 +import java.util.Set;
  22 +
  23 +/**
  24 + * 简道云表单 -> 启效 结构/数据同步。
  25 + * <p>
  26 + * 结构同步:把简道云表单字段镜像到启效指定应用下(新建表单模型 + 写字段)。
  27 + * 数据同步:只抓当月数据;每次新增一份完整数据留存;若「当天」已有同步数据,先删当天再同步。
  28 + */
  29 +@Slf4j
  30 +@Service
  31 +public class JdyFormSyncService {
  32 +
  33 + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
  34 + private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyy-MM");
  35 + private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
  36 +
  37 + private final JdyFormSyncProperties props;
  38 +
  39 + public JdyFormSyncService(JdyFormSyncProperties props) {
  40 + this.props = props;
  41 + }
  42 +
  43 + /* ==================== 结构同步 ==================== */
  44 +
  45 + /** 同步所有配置表单的「结构」(字段),返回摘要。 */
  46 + public List<String> syncStructures() {
  47 + List<String> result = new ArrayList<>();
  48 + JdyFormSyncProperties.Qx qx = props.getQx();
  49 + QxApi qxApi = new QxApi(qx.getBaseUrl());
  50 + String token = qxApi.login(qx.getUsername(), qx.getPassword(), qx.getCorpCode());
  51 + JiandaoYunApi jdy = new JiandaoYunApi(props.getJdy().getHost());
  52 +
  53 + for (JdyFormSyncProperties.Form f : props.getForms()) {
  54 + String formName = resolveFormName(jdy, f);
  55 + String funCode = funCode(f, formName);
  56 + JSONArray widgets = jdy.listWidgets(props.getJdy().getApiKey(), f.getAppId(), f.getEntryId());
  57 + List<QxApi.Field> fields = buildFields(widgets, f);
  58 +
  59 + String modelId = f.getModelId();
  60 + boolean createNew = (modelId == null || modelId.isEmpty());
  61 + if (createNew) {
  62 + modelId = qxApi.createFormModel(token, qx.getAppId(), formName, funCode);
  63 + }
  64 + qxApi.saveDesignObj(token, qx.getAppId(), qx.getAppName(), modelId, formName, funCode, fields);
  65 + String summary = (createNew ? "OK 新建" : "OK 更新") + "表单「" + formName + "」 funCode=" + funCode
  66 + + " modelId=" + modelId + " 字段数=" + fields.size() + " -> " + fieldsText(fields);
  67 + log.info(summary);
  68 + result.add(summary);
  69 + }
  70 + return result;
  71 + }
  72 +
  73 + /* ==================== 数据同步 ==================== */
  74 +
  75 + /**
  76 + * 同步所有配置表单的「数据」:当月数据、当天去重。
  77 + * <p>
  78 + * 流程:解析接口 code -> 删除启效中「当天」已同步的记录 -> 拉简道云当月数据 -> 逐条新增。
  79 + */
  80 + public List<String> syncData() {
  81 + List<String> result = new ArrayList<>();
  82 + JdyFormSyncProperties.Qx qx = props.getQx();
  83 + QxApi qxApi = new QxApi(qx.getBaseUrl());
  84 + String token = qxApi.login(qx.getUsername(), qx.getPassword(), qx.getCorpCode());
  85 + JiandaoYunApi jdy = new JiandaoYunApi(props.getJdy().getHost());
  86 + String month = (props.getMonth() != null && !props.getMonth().isEmpty())
  87 + ? props.getMonth() : ZonedDateTime.now(ZONE).format(MONTH_FMT);
  88 +
  89 + for (JdyFormSyncProperties.Form f : props.getForms()) {
  90 + try {
  91 + String formName = resolveFormName(jdy, f);
  92 + if (f.getModelId() == null || f.getModelId().isEmpty()) {
  93 + throw new IllegalStateException("未配置 model-id,无法定位启效表单接口");
  94 + }
  95 + log.info("开始同步数据: 表单「{}」 modelId={} 当月={}", formName, f.getModelId(), month);
  96 +
  97 + // 字段映射:简道云控件 code -> 启效 fieldKey;并记录日期字段
  98 + JSONArray widgets = jdy.listWidgets(props.getJdy().getApiKey(), f.getAppId(), f.getEntryId());
  99 + Map<String, String> codeToField = new LinkedHashMap<>();
  100 + Set<String> dateFieldKeys = new HashSet<>();
  101 + buildFieldMapping(widgets, f, codeToField, dateFieldKeys);
  102 +
  103 + // 解析启效接口 code(新增/分页/删除)
  104 + JSONArray ifaces = qxApi.listApiManages(token, qx.getAppId());
  105 + String addCode = findModelCode(ifaces, f.getModelId(), "Add");
  106 + String pageCode = findModelCode(ifaces, f.getModelId(), "Page");
  107 + String delCode = findModelCode(ifaces, f.getModelId(), "Del");
  108 + if (addCode == null || pageCode == null || delCode == null) {
  109 + throw new IllegalStateException("未找到接口 code add=" + addCode + " page=" + pageCode + " del=" + delCode);
  110 + }
  111 + log.info("启效接口: add={} page={} del={}", addCode, pageCode, delCode);
  112 +
  113 + // 删除「当天」已同步的记录
  114 + int deleted = deleteTodayData(qxApi, token, qx.getAppId(), pageCode, delCode);
  115 + log.info("删除当天已存在数据 {} 条", deleted);
  116 +
  117 + // 拉简道云当月数据
  118 + List<JSONObject> records = fetchMonthData(jdy, f, month, dateFieldKeys);
  119 + // 注意: fetchMonthData 内部按配置的 date-field 过滤
  120 +
  121 + // 逐条新增
  122 + int ok = 0, fail = 0;
  123 + for (JSONObject rec : records) {
  124 + try {
  125 + Map<String, Object> payload = buildPayload(rec, widgets, codeToField, dateFieldKeys);
  126 + qxApi.insertData(token, qx.getAppId(), addCode, payload);
  127 + ok++;
  128 + } catch (Exception e) {
  129 + fail++;
  130 + log.error("新增失败(继续下一条): {}", e.getMessage());
  131 + }
  132 + }
  133 + String summary = "OK " + formName + " 当月=" + month + " 删除当天=" + deleted
  134 + + " 新增=" + ok + " 失败=" + fail;
  135 + log.info(summary);
  136 + result.add(summary);
  137 + } catch (Exception e) {
  138 + log.error("同步表单数据失败", e);
  139 + result.add("FAIL " + f.getEntryId() + " : " + e.getMessage());
  140 + }
  141 + }
  142 + return result;
  143 + }
  144 +
  145 + /** 拉取简道云「当月」数据(按配置 date-field 过滤)。 */
  146 + private List<JSONObject> fetchMonthData(JiandaoYunApi jdy, JdyFormSyncProperties.Form f,
  147 + String month, Set<String> dateFieldKeys) {
  148 + JSONArray all = jdy.listAllData(props.getJdy().getApiKey(), f.getAppId(), f.getEntryId());
  149 + List<JSONObject> monthRecords = new ArrayList<>();
  150 + String dateField = f.getDateField();
  151 + if (!f.isMonthOnly() || dateField == null || dateField.isEmpty()) {
  152 + // 全量同步
  153 + for (int i = 0; i < all.size(); i++) {
  154 + monthRecords.add(all.getJSONObject(i));
  155 + }
  156 + log.info("简道云全量 {} 条(未按月份过滤)", all.size());
  157 + return monthRecords;
  158 + }
  159 + for (int i = 0; i < all.size(); i++) {
  160 + JSONObject rec = all.getJSONObject(i);
  161 + String iso = rec.getString(dateField);
  162 + if (month.equals(toMonth(iso))) {
  163 + monthRecords.add(rec);
  164 + }
  165 + }
  166 + log.info("简道云全量 {} 条, 当月({}) {} 条", all.size(), month, monthRecords.size());
  167 + return monthRecords;
  168 + }
  169 +
  170 + /** 删除启效中「当天」已同步的记录,返回删除条数。 */
  171 + private int deleteTodayData(QxApi qxApi, String token, String appId, String pageCode, String delCode) {
  172 + JSONArray existing = qxApi.pageAll(token, appId, pageCode);
  173 + String today = ZonedDateTime.now(ZONE).format(DAY_FMT);
  174 + int deleted = 0;
  175 + for (int i = 0; i < existing.size(); i++) {
  176 + JSONObject rec = existing.getJSONObject(i);
  177 + if (isToday(rec, today)) {
  178 + String id = rec.getString("id");
  179 + if (id != null && !id.isEmpty()) {
  180 + qxApi.deleteData(token, appId, delCode, id);
  181 + deleted++;
  182 + }
  183 + }
  184 + }
  185 + return deleted;
  186 + }
  187 +
  188 + private boolean isToday(JSONObject rec, String today) {
  189 + Long ca = rec.getLong("createdAt");
  190 + if (ca != null) {
  191 + return Instant.ofEpochMilli(ca).atZone(ZONE).format(DAY_FMT).equals(today);
  192 + }
  193 + Object ct = rec.get("createTime");
  194 + if (ct != null) {
  195 + String day = toDay(String.valueOf(ct));
  196 + return today.equals(day);
  197 + }
  198 + return false;
  199 + }
  200 +
  201 + /** 在接口列表中,按 modelId 找 code 以指定后缀结尾的接口(Add/Page/Del)。 */
  202 + private String findModelCode(JSONArray ifaces, String modelId, String suffix) {
  203 + if (ifaces == null) {
  204 + return null;
  205 + }
  206 + for (int i = 0; i < ifaces.size(); i++) {
  207 + JSONObject api = ifaces.getJSONObject(i);
  208 + String mid = api.getString("modelId");
  209 + String code = api.getString("code");
  210 + if (code != null && code.endsWith(suffix) && modelId.equals(mid)) {
  211 + return code;
  212 + }
  213 + }
  214 + return null;
  215 + }
  216 +
  217 + /* ==================== 公共辅助 ==================== */
  218 +
  219 + /** 解析简道云表单名:优先从简道云 entry/list 取(UTF-8,避免配置编码问题),取不到再用配置名。 */
  220 + private String resolveFormName(JiandaoYunApi jdy, JdyFormSyncProperties.Form f) {
  221 + JSONArray forms = jdy.listAllForms(props.getJdy().getApiKey(), f.getAppId());
  222 + for (int i = 0; i < forms.size(); i++) {
  223 + JSONObject form = forms.getJSONObject(i);
  224 + if (f.getEntryId().equals(form.getString("entry_id"))) {
  225 + String name = form.getString("name");
  226 + if (name != null && !name.isEmpty()) {
  227 + return name;
  228 + }
  229 + }
  230 + }
  231 + if (f.getName() != null && !f.getName().isEmpty()) {
  232 + return f.getName();
  233 + }
  234 + throw new IllegalStateException("简道云未找到表单 entry_id=" + f.getEntryId());
  235 + }
  236 +
  237 + private String funCode(JdyFormSyncProperties.Form f, String formName) {
  238 + return (f.getFunCode() == null || f.getFunCode().isEmpty())
  239 + ? FieldMapping.genFunCode(formName) : f.getFunCode();
  240 + }
  241 +
  242 + /** 构建启效字段列表(结构同步用)。 */
  243 + private List<QxApi.Field> buildFields(JSONArray widgets, JdyFormSyncProperties.Form f) {
  244 + List<QxApi.Field> fields = new ArrayList<>();
  245 + Set<String> usedFieldKey = new LinkedHashSet<>();
  246 + Set<String> usedLabel = new HashSet<>();
  247 + for (int i = 0; i < widgets.size(); i++) {
  248 + JSONObject w = widgets.getJSONObject(i);
  249 + String label = w.getString("label");
  250 + if (label == null || label.isEmpty()) {
  251 + label = w.getString("widgetName");
  252 + }
  253 + if (label == null || label.isEmpty() || !usedLabel.add(label)) {
  254 + continue;
  255 + }
  256 + String fieldKey = FieldMapping.genFieldKey(label, f.getFieldMapping(), usedFieldKey);
  257 + String qxType = FieldMapping.mapType(w.getString("type"));
  258 + fields.add(new QxApi.Field(label, fieldKey, qxType));
  259 + }
  260 + return fields;
  261 + }
  262 +
  263 + /** 构建 控件code -> fieldKey 映射,并收集日期字段(数据同步用)。 */
  264 + private void buildFieldMapping(JSONArray widgets, JdyFormSyncProperties.Form f,
  265 + Map<String, String> codeToField, Set<String> dateFieldKeys) {
  266 + Set<String> usedFieldKey = new LinkedHashSet<>();
  267 + Set<String> usedLabel = new HashSet<>();
  268 + for (int i = 0; i < widgets.size(); i++) {
  269 + JSONObject w = widgets.getJSONObject(i);
  270 + String code = w.getString("name");
  271 + String label = w.getString("label");
  272 + if (label == null || label.isEmpty()) {
  273 + label = w.getString("widgetName");
  274 + }
  275 + if (label == null || label.isEmpty() || !usedLabel.add(label)) {
  276 + continue;
  277 + }
  278 + String jdyType = w.getString("type");
  279 + String fieldKey = FieldMapping.genFieldKey(label, f.getFieldMapping(), usedFieldKey);
  280 + codeToField.put(code, fieldKey);
  281 + if (FieldMapping.isDateJdy(jdyType)) {
  282 + dateFieldKeys.add(fieldKey);
  283 + }
  284 + }
  285 + }
  286 +
  287 + /** 组装单条写入 payload:日期转毫秒;用户/人员字段取 name。 */
  288 + private Map<String, Object> buildPayload(JSONObject record, JSONArray widgets,
  289 + Map<String, String> codeToField, Set<String> dateFieldKeys) {
  290 + Map<String, Object> payload = new LinkedHashMap<>();
  291 + for (int i = 0; i < widgets.size(); i++) {
  292 + JSONObject w = widgets.getJSONObject(i);
  293 + String code = w.getString("name");
  294 + String fieldKey = codeToField.get(code);
  295 + if (fieldKey == null) {
  296 + continue;
  297 + }
  298 + Object v = record.get(code);
  299 + if (v == null) {
  300 + continue;
  301 + }
  302 + if (dateFieldKeys.contains(fieldKey) && v instanceof String) {
  303 + v = toMs((String) v);
  304 + } else if (v instanceof JSONObject) {
  305 + JSONObject o = (JSONObject) v;
  306 + v = o.getString("name") != null ? o.getString("name") : o.toJSONString();
  307 + } else if (v instanceof JSONArray) {
  308 + JSONArray arr = (JSONArray) v;
  309 + StringBuilder sb = new StringBuilder();
  310 + for (int k = 0; k < arr.size(); k++) {
  311 + JSONObject o = arr.getJSONObject(k);
  312 + String n = o == null ? null : o.getString("name");
  313 + if (n == null) {
  314 + n = String.valueOf(arr.get(k));
  315 + }
  316 + if (sb.length() > 0) {
  317 + sb.append(",");
  318 + }
  319 + sb.append(n);
  320 + }
  321 + v = sb.toString();
  322 + }
  323 + payload.put(fieldKey, v);
  324 + }
  325 + return payload;
  326 + }
  327 +
  328 + private static String fieldsText(List<QxApi.Field> fields) {
  329 + StringBuilder sb = new StringBuilder();
  330 + for (QxApi.Field f : fields) {
  331 + if (sb.length() > 0) {
  332 + sb.append(", ");
  333 + }
  334 + sb.append(f.getTitle()).append("->").append(f.getFieldKey()).append("(").append(f.getType()).append(")");
  335 + }
  336 + return sb.toString();
  337 + }
  338 +
  339 + /** ISO 时间 -> 北京时间 yyyy-MM;解析失败返回 null。 */
  340 + private static String toMonth(String iso) {
  341 + String day = toDay(iso);
  342 + return day == null ? null : day.substring(0, 7);
  343 + }
  344 +
  345 + /** ISO 时间 -> 北京时间 yyyy-MM-dd;解析失败返回 null。 */
  346 + private static String toDay(String iso) {
  347 + if (iso == null || iso.isEmpty()) {
  348 + return null;
  349 + }
  350 + try {
  351 + return Instant.parse(iso).atZone(ZONE).format(DAY_FMT);
  352 + } catch (Exception e) {
  353 + return null;
  354 + }
  355 + }
  356 +
  357 + public static Object toMs(String iso) {
  358 + try {
  359 + return Instant.parse(iso).toEpochMilli();
  360 + } catch (Exception e) {
  361 + try {
  362 + java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  363 + sdf.setTimeZone(java.util.TimeZone.getTimeZone("Asia/Shanghai"));
  364 + return sdf.parse(iso).getTime();
  365 + } catch (Exception ex) {
  366 + return iso;
  367 + }
  368 + }
  369 + }
  370 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.service;
  2 +
  3 +import com.alibaba.fastjson.JSONArray;
  4 +import com.alibaba.fastjson.JSONObject;
  5 +import com.qgutech.qgyun.apaas.sync.jdy.api.JiandaoYunApi;
  6 +import com.qgutech.qgyun.apaas.sync.jdy.api.QxApi;
  7 +import com.qgutech.qgyun.apaas.sync.jdy.config.JdyRiskProperties;
  8 +import lombok.extern.slf4j.Slf4j;
  9 +import org.springframework.stereotype.Service;
  10 +
  11 +import java.io.File;
  12 +import java.time.Instant;
  13 +import java.time.ZoneId;
  14 +import java.time.ZonedDateTime;
  15 +import java.time.format.DateTimeFormatter;
  16 +import java.time.temporal.ChronoUnit;
  17 +import java.util.ArrayList;
  18 +import java.util.LinkedHashMap;
  19 +import java.util.LinkedHashSet;
  20 +import java.util.List;
  21 +import java.util.Map;
  22 +import java.util.Set;
  23 +
  24 +/**
  25 + * 风险场景统计:调用长安简道云开放 API 读取表单数据,按规则过滤并输出命中计数;
  26 + * 并可按 syncCode 把「计数 + 简道云原始数据 Excel 附件」写回启效表单。
  27 + */
  28 +@Slf4j
  29 +@Service
  30 +public class JdyRiskStatisticsService {
  31 +
  32 + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
  33 + private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyy-MM");
  34 +
  35 + private final JdyRiskProperties props;
  36 + private final JdyExcelExportService excelExportService;
  37 +
  38 + public JdyRiskStatisticsService(JdyRiskProperties props, JdyExcelExportService excelExportService) {
  39 + this.props = props;
  40 + this.excelExportService = excelExportService;
  41 + }
  42 +
  43 + /** 只统计,返回 {count_1:.., count_2:.., ...}。 */
  44 + public Map<String, Object> countAll() {
  45 + Map<String, Object> result = new LinkedHashMap<>();
  46 + JiandaoYunApi jdy = new JiandaoYunApi(props.getHost());
  47 + for (JdyRiskProperties.Scenario s : props.getScenarios()) {
  48 + try {
  49 + String type = s.getType() == null ? "" : s.getType().toLowerCase();
  50 + if ("scenario7".equals(type)) {
  51 + result.put(s.getId(), buildScenario7Rows(jdy, s).size());
  52 + } else {
  53 + JSONArray all = jdy.listAllData(props.getApiKey(), s.getAppId(), s.getEntryId());
  54 + JSONArray rows = filterScope(all, s);
  55 + result.put(s.getId(), count(s, rows));
  56 + }
  57 + } catch (Exception e) {
  58 + log.error("场景[{}] 统计失败", s.getId(), e);
  59 + result.put(s.getId(), "ERROR: " + e.getMessage());
  60 + }
  61 + }
  62 + return result;
  63 + }
  64 +
  65 + /** 统计全部场景并按 syncCode 把计数 + Excel 附件写回启效。 */
  66 + public Map<String, Object> countAndSync() {
  67 + log.info("========== 开始风险场景统计并写回启效(全部 {} 个场景) ==========", props.getScenarios().size());
  68 + Map<String, Object> result = new LinkedHashMap<>();
  69 + Context ctx = context();
  70 + for (JdyRiskProperties.Scenario s : props.getScenarios()) {
  71 + syncOneScenario(s, ctx, result);
  72 + }
  73 + log.info("========== 风险场景统计并写回完成 ==========");
  74 + return result;
  75 + }
  76 +
  77 + /** 只处理单个场景(参数为场景 id 如 count_1,或 syncCode 如 SYNC0001)。 */
  78 + public Map<String, Object> countAndSyncOne(String scenarioId) {
  79 + Map<String, Object> result = new LinkedHashMap<>();
  80 + JdyRiskProperties.Scenario s = findScenario(scenarioId);
  81 + if (s == null) {
  82 + result.put("error", "场景不存在: " + scenarioId);
  83 + return result;
  84 + }
  85 + log.info("========== 开始单场景统计并写回: {} ==========", scenarioId);
  86 + syncOneScenario(s, context(), result);
  87 + return result;
  88 + }
  89 +
  90 + /** 只统计单个场景(不写回)。 */
  91 + public Map<String, Object> countOne(String scenarioId) {
  92 + Map<String, Object> result = new LinkedHashMap<>();
  93 + JdyRiskProperties.Scenario s = findScenario(scenarioId);
  94 + if (s == null) {
  95 + result.put("error", "场景不存在: " + scenarioId);
  96 + return result;
  97 + }
  98 + JiandaoYunApi jdy = new JiandaoYunApi(props.getHost());
  99 + try {
  100 + String type = s.getType() == null ? "" : s.getType().toLowerCase();
  101 + if ("scenario7".equals(type)) {
  102 + result.put(s.getId(), buildScenario7Rows(jdy, s).size());
  103 + } else {
  104 + JSONArray all = jdy.listAllData(props.getApiKey(), s.getAppId(), s.getEntryId());
  105 + result.put(s.getId(), count(s, filterScope(all, s)));
  106 + }
  107 + } catch (Exception e) {
  108 + result.put(s.getId(), "ERROR: " + e.getMessage());
  109 + }
  110 + return result;
  111 + }
  112 +
  113 + private JdyRiskProperties.Scenario findScenario(String scenarioId) {
  114 + if (scenarioId == null) {
  115 + return null;
  116 + }
  117 + for (JdyRiskProperties.Scenario s : props.getScenarios()) {
  118 + if (scenarioId.equals(s.getId()) || scenarioId.equals(s.getSyncCode())) {
  119 + return s;
  120 + }
  121 + }
  122 + return null;
  123 + }
  124 +
  125 + /** 登录启效并一次性拉取全部记录(本地按 syncCode 匹配)。 */
  126 + private Context context() {
  127 + JdyRiskProperties.Qx qx = props.getQx();
  128 + QxApi qxApi = new QxApi(qx.getBaseUrl());
  129 + String token = qxApi.login(qx.getUsername(), qx.getPassword(), qx.getCorpCode());
  130 + JiandaoYunApi jdy = new JiandaoYunApi(props.getHost());
  131 + JSONArray allRecords = qxApi.pageAll(token, qx.getAppId(), qx.getQueryCode());
  132 + log.info("启效记录总数={}(按 syncCode 本地匹配)", allRecords.size());
  133 + return new Context(qxApi, token, jdy, allRecords);
  134 + }
  135 +
  136 + private static class Context {
  137 + final QxApi qxApi;
  138 + final String token;
  139 + final JiandaoYunApi jdy;
  140 + final JSONArray allRecords;
  141 +
  142 + Context(QxApi qxApi, String token, JiandaoYunApi jdy, JSONArray allRecords) {
  143 + this.qxApi = qxApi;
  144 + this.token = token;
  145 + this.jdy = jdy;
  146 + this.allRecords = allRecords;
  147 + }
  148 + }
  149 +
  150 + /** 单场景:统计 -> 生成 Excel -> 上传附件 -> 更新记录。 */
  151 + private void syncOneScenario(JdyRiskProperties.Scenario s, Context ctx, Map<String, Object> result) {
  152 + JdyRiskProperties.Qx qx = props.getQx();
  153 + QxApi qxApi = ctx.qxApi;
  154 + String token = ctx.token;
  155 + JiandaoYunApi jdy = ctx.jdy;
  156 + JSONArray allRecords = ctx.allRecords;
  157 + try {
  158 + String type = s.getType() == null ? "" : s.getType().toLowerCase();
  159 + JSONArray rows;
  160 + long count;
  161 + if ("scenario7".equals(type)) {
  162 + rows = buildScenario7Rows(jdy, s);
  163 + count = rows.size();
  164 + } else {
  165 + JSONArray all = jdy.listAllData(props.getApiKey(), s.getAppId(), s.getEntryId());
  166 + rows = filterScope(all, s);
  167 + count = count(s, rows);
  168 + }
  169 + result.put(s.getId(), count);
  170 + log.info("场景[{}] 命中={} (参与统计{}条)", s.getId(), count, rows.size());
  171 +
  172 + JSONArray widgets = jdy.listWidgets(props.getApiKey(), s.getAppId(), s.getEntryId());
  173 + String extraHeader = null;
  174 + List<String> extraValues = null;
  175 + if (!"scenario7".equals(type)) {
  176 + if ("scenario4".equals(type) || "scenario6".equals(type) || "scenario8".equals(type)) {
  177 + extraHeader = "预警供应商";
  178 + } else if ("scenario5".equals(type) || "scenario9".equals(type)) {
  179 + extraHeader = "预警工程师";
  180 + } else {
  181 + extraHeader = "筛选结果";
  182 + }
  183 + extraValues = buildExtraValues(s, rows);
  184 + }
  185 + String fileName = JdyExcelExportService.nowFileName();
  186 + String path = excelExportService.exportRows(widgets, rows, extraHeader, extraValues, fileName);
  187 + if (path == null || path.startsWith("FAIL")) {
  188 + throw new IllegalStateException("Excel 导出失败: " + path);
  189 + }
  190 +
  191 + String fileId = qxApi.uploadFile(token, new File(path), fileName);
  192 +
  193 + String syncCode = s.getSyncCode();
  194 + JSONObject rec = findRecordBySyncCode(allRecords, syncCode);
  195 + if (rec == null) {
  196 + log.warn("按 syncCode={} 未查到记录,跳过写回", syncCode);
  197 + return;
  198 + }
  199 + rec.put(qx.getCountField(), count);
  200 + if (qx.getAttachField() != null && !qx.getAttachField().isEmpty()) {
  201 + JSONObject rel = new JSONObject();
  202 + rel.put("id", fileId);
  203 + rel.put("dataTitle", fileName);
  204 + rec.put(qx.getAttachField(), rel);
  205 + rec.put(qx.getAttachField() + "Id", fileId);
  206 + }
  207 + qxApi.updateData(token, qx.getAppId(), qx.getUpdateCode(), rec);
  208 + log.info("写回成功: syncCode={} id={} {}={} 附件={}", syncCode, rec.getString("id"),
  209 + qx.getCountField(), count, fileName);
  210 + } catch (Exception e) {
  211 + log.error("场景[{}] 写回失败", s.getId(), e);
  212 + result.put(s.getId() + "_error", e.getMessage());
  213 + }
  214 + }
  215 +
  216 + /* ==================== 范围过滤 ==================== */
  217 +
  218 + /** 按场景统计周期过滤数据:全量 / 当月 / 当季度 / 当年 / 上月。 */
  219 + private JSONArray filterScope(JSONArray all, JdyRiskProperties.Scenario s) {
  220 + if (!s.isMonthOnly() && !s.isQuarterOnly() && !s.isYearOnly() && !s.isPrevMonthOnly()) {
  221 + return all;
  222 + }
  223 + ZonedDateTime now = ZonedDateTime.now(ZONE);
  224 + ZonedDateTime qStart = quarterStart(now);
  225 + ZonedDateTime qEnd = qStart.plusMonths(3);
  226 + ZonedDateTime yStart = now.withDayOfYear(1).truncatedTo(ChronoUnit.DAYS);
  227 + ZonedDateTime pmStart = now.minusMonths(1).withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
  228 + ZonedDateTime pmEnd = now.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
  229 + String month = now.format(MONTH_FMT);
  230 + JSONArray out = new JSONArray();
  231 + for (int i = 0; i < all.size(); i++) {
  232 + JSONObject row = all.getJSONObject(i);
  233 + ZonedDateTime dt = parseTime(row.getString(s.getDateField()));
  234 + if (dt == null) {
  235 + continue;
  236 + }
  237 + boolean keep = false;
  238 + if (s.isQuarterOnly()) {
  239 + keep = !dt.isBefore(qStart) && dt.isBefore(qEnd);
  240 + } else if (s.isYearOnly()) {
  241 + keep = !dt.isBefore(yStart) && !dt.isAfter(now);
  242 + } else if (s.isPrevMonthOnly()) {
  243 + keep = !dt.isBefore(pmStart) && dt.isBefore(pmEnd);
  244 + } else if (s.isMonthOnly()) {
  245 + keep = month.equals(dt.format(MONTH_FMT));
  246 + }
  247 + if (keep) {
  248 + out.add(row);
  249 + }
  250 + }
  251 + return out;
  252 + }
  253 +
  254 + /* ==================== 计数 ==================== */
  255 +
  256 + private long count(JdyRiskProperties.Scenario s, JSONArray rows) {
  257 + String type = s.getType() == null ? "" : s.getType().toLowerCase();
  258 + switch (type) {
  259 + case "scenario1":
  260 + return countScenario1(s, rows);
  261 + case "scenario2":
  262 + return countScenario2(s, rows);
  263 + case "scenario3":
  264 + return countScenario3(s, rows);
  265 + case "scenario4":
  266 + case "scenario5":
  267 + return countGroupByUp(s, rows);
  268 + case "scenario6":
  269 + return warningGroupsByCount(s, rows).size();
  270 + case "scenario8":
  271 + case "scenario9":
  272 + return warningGroupsByRatio(s, rows).size();
  273 + default:
  274 + throw new IllegalStateException("未知场景类型: " + s.getType());
  275 + }
  276 + }
  277 +
  278 + /** 场景1:总计>=5 且 差值>=1 且 flowState=进行中。 */
  279 + private long countScenario1(JdyRiskProperties.Scenario s, JSONArray data) {
  280 + long hit = 0;
  281 + for (int i = 0; i < data.size(); i++) {
  282 + JSONObject row = data.getJSONObject(i);
  283 + double total = num(row, s.getTotalField());
  284 + double diff = num(row, s.getDiffField());
  285 + Integer flow = row.getInteger("flowState");
  286 + if (total >= 5 && diff >= 1 && flow != null && flow == s.getInProgressState()) {
  287 + hit++;
  288 + }
  289 + }
  290 + return hit;
  291 + }
  292 +
  293 + /** 场景2:按供应商分组,SI数量/问题数量合计>60% 且 合计>=5 的供应商个数。 */
  294 + private long countScenario2(JdyRiskProperties.Scenario s, JSONArray data) {
  295 + Map<String, double[]> byGroup = new LinkedHashMap<>();
  296 + for (int i = 0; i < data.size(); i++) {
  297 + JSONObject row = data.getJSONObject(i);
  298 + String group = orEmpty(row.getString(s.getGroupField()));
  299 + double qty = num(row, s.getQtyField());
  300 + double[] agg = byGroup.computeIfAbsent(group, k -> new double[2]);
  301 + agg[0] += qty;
  302 + if ("SI".equalsIgnoreCase(row.getString(s.getSiField()))) {
  303 + agg[1] += qty;
  304 + }
  305 + }
  306 + long hit = 0;
  307 + for (double[] agg : byGroup.values()) {
  308 + if (agg[0] >= 5 && (agg[1] / agg[0]) > 0.6) {
  309 + hit++;
  310 + }
  311 + }
  312 + return hit;
  313 + }
  314 +
  315 + /** 场景3:SI占比>60% 且 总问题数>=5 的行数(工程师个数)。 */
  316 + private long countScenario3(JdyRiskProperties.Scenario s, JSONArray data) {
  317 + long hit = 0;
  318 + for (int i = 0; i < data.size(); i++) {
  319 + JSONObject row = data.getJSONObject(i);
  320 + double total = num(row, s.getTotalField());
  321 + double ratio = num(row, s.getRatioField());
  322 + if (total >= 5 && ratio > 0.6) {
  323 + hit++;
  324 + }
  325 + }
  326 + return hit;
  327 + }
  328 +
  329 + /** 场景4/5:升级不含排除值且非空,按分组计数,分组次数 >= minCount 的命中分组个数。 */
  330 + private long countGroupByUp(JdyRiskProperties.Scenario s, JSONArray data) {
  331 + return warningGroupsByUp(s, data).size();
  332 + }
  333 +
  334 + /** 场景4/5:命中分组集合(升级不含排除值且非空,分组计数 >= minCount)。 */
  335 + private Set<String> warningGroupsByUp(JdyRiskProperties.Scenario s, JSONArray data) {
  336 + Map<String, Integer> counts = new LinkedHashMap<>();
  337 + for (int i = 0; i < data.size(); i++) {
  338 + JSONObject row = data.getJSONObject(i);
  339 + if (isUpHit(s, row)) {
  340 + String g = orEmpty(row.getString(s.getGroupField()));
  341 + counts.merge(g, 1, Integer::sum);
  342 + }
  343 + }
  344 + int threshold = Math.max(1, s.getMinCount());
  345 + Set<String> warn = new LinkedHashSet<>();
  346 + for (Map.Entry<String, Integer> e : counts.entrySet()) {
  347 + if (e.getValue() >= threshold) {
  348 + warn.add(e.getKey());
  349 + }
  350 + }
  351 + return warn;
  352 + }
  353 +
  354 + /** 场景6:按分组字段计数,分组次数 >= minCount 的命中分组集合。 */
  355 + private Set<String> warningGroupsByCount(JdyRiskProperties.Scenario s, JSONArray data) {
  356 + Map<String, Integer> counts = new LinkedHashMap<>();
  357 + for (int i = 0; i < data.size(); i++) {
  358 + JSONObject row = data.getJSONObject(i);
  359 + String g = orEmpty(row.getString(s.getGroupField()));
  360 + counts.merge(g, 1, Integer::sum);
  361 + }
  362 + int threshold = Math.max(1, s.getMinCount());
  363 + Set<String> warn = new LinkedHashSet<>();
  364 + for (Map.Entry<String, Integer> e : counts.entrySet()) {
  365 + if (e.getValue() >= threshold) {
  366 + warn.add(e.getKey());
  367 + }
  368 + }
  369 + return warn;
  370 + }
  371 +
  372 + /** 场景8/9:按分组字段,flagField==flagValue 的条数 / 总条数 > ratioThreshold 的命中分组集合。 */
  373 + private Set<String> warningGroupsByRatio(JdyRiskProperties.Scenario s, JSONArray data) {
  374 + Map<String, int[]> byGroup = new LinkedHashMap<>(); // [总数, 命中数]
  375 + for (int i = 0; i < data.size(); i++) {
  376 + JSONObject row = data.getJSONObject(i);
  377 + String g = orEmpty(row.getString(s.getGroupField()));
  378 + int[] agg = byGroup.computeIfAbsent(g, k -> new int[2]);
  379 + agg[0]++;
  380 + if (s.getFlagValue() != null && s.getFlagValue().equalsIgnoreCase(row.getString(s.getFlagField()))) {
  381 + agg[1]++;
  382 + }
  383 + }
  384 + Set<String> warn = new LinkedHashSet<>();
  385 + double th = s.getRatioThreshold();
  386 + for (Map.Entry<String, int[]> e : byGroup.entrySet()) {
  387 + int[] agg = e.getValue();
  388 + if (agg[0] > 0 && ((double) agg[1] / agg[0]) > th) {
  389 + warn.add(e.getKey());
  390 + }
  391 + }
  392 + return warn;
  393 + }
  394 +
  395 + /**
  396 + * 场景7:多表关联 + 超期过滤(分批流式,防大表 OOM)。
  397 + * <p>① 收集排除表单的条码集合;② 流式遍历主表:条码命中排除集合则丢弃;③ 剩余按入库时间超过 maxAgeDays 天保留。
  398 + */
  399 + private JSONArray buildScenario7Rows(JiandaoYunApi jdy, JdyRiskProperties.Scenario s) {
  400 + Set<String> exclude = new LinkedHashSet<>();
  401 + for (JdyRiskProperties.ExcludeForm ef : s.getExcludeForms()) {
  402 + if (ef == null || ef.getEntryId() == null || ef.getBarcodeField() == null) {
  403 + continue;
  404 + }
  405 + final JdyRiskProperties.ExcludeForm cur = ef;
  406 + jdy.forEachPage(props.getApiKey(), s.getAppId(), cur.getEntryId(),
  407 + java.util.Collections.singletonList(cur.getBarcodeField()), page -> {
  408 + for (int i = 0; i < page.size(); i++) {
  409 + String b = page.getJSONObject(i).getString(cur.getBarcodeField());
  410 + if (b != null && !b.trim().isEmpty()) {
  411 + exclude.add(b.trim());
  412 + }
  413 + }
  414 + });
  415 + log.info("场景7 排除来源 entry={} 累计条码={}", ef.getEntryId(), exclude.size());
  416 + }
  417 +
  418 + java.time.Instant cutoff = ZonedDateTime.now(ZONE)
  419 + .minusDays(Math.max(0, s.getMaxAgeDays())).toInstant();
  420 + JSONArray kept = new JSONArray();
  421 + final int[] scanned = {0};
  422 + jdy.forEachPage(props.getApiKey(), s.getAppId(), s.getEntryId(), s.getFields(), page -> {
  423 + for (int i = 0; i < page.size(); i++) {
  424 + scanned[0]++;
  425 + JSONObject row = page.getJSONObject(i);
  426 + String barcode = row.getString(s.getBarcodeField());
  427 + if (barcode != null && exclude.contains(barcode.trim())) {
  428 + continue; // 条码在出库/抵扣表中出现 → 过滤掉
  429 + }
  430 + ZonedDateTime inTime = parseTime(row.getString(s.getDateField()));
  431 + if (inTime == null) {
  432 + continue;
  433 + }
  434 + if (inTime.toInstant().isBefore(cutoff)) {
  435 + kept.add(row); // 入库时间超过 maxAgeDays 天
  436 + }
  437 + }
  438 + });
  439 + log.info("场景7 主表扫描={}条, 过滤后保留={}条 (排除条码={}, 超期阈值={}天)",
  440 + scanned[0], kept.size(), exclude.size(), s.getMaxAgeDays());
  441 + return kept;
  442 + }
  443 +
  444 + /** 场景2:命中供应商集合(SI数量/问题数量合计>60% 且 合计>=5)。 */
  445 + private Set<String> warningSuppliers2(JdyRiskProperties.Scenario s, JSONArray data) {
  446 + Map<String, double[]> byGroup = new LinkedHashMap<>();
  447 + for (int i = 0; i < data.size(); i++) {
  448 + JSONObject row = data.getJSONObject(i);
  449 + String group = orEmpty(row.getString(s.getGroupField()));
  450 + double qty = num(row, s.getQtyField());
  451 + double[] agg = byGroup.computeIfAbsent(group, k -> new double[2]);
  452 + agg[0] += qty;
  453 + if ("SI".equalsIgnoreCase(row.getString(s.getSiField()))) {
  454 + agg[1] += qty;
  455 + }
  456 + }
  457 + Set<String> warn = new LinkedHashSet<>();
  458 + for (Map.Entry<String, double[]> e : byGroup.entrySet()) {
  459 + double[] agg = e.getValue();
  460 + if (agg[0] >= 5 && (agg[1] / agg[0]) > 0.6) {
  461 + warn.add(e.getKey());
  462 + }
  463 + }
  464 + return warn;
  465 + }
  466 +
  467 + /**
  468 + * Excel 最右附加列的值(各场景统一):
  469 + * 命中/预警的行显示对应名称,其余为空。
  470 + * 取值优先级:extra-field > group-field > 「命中」。
  471 + */
  472 + private List<String> buildExtraValues(JdyRiskProperties.Scenario s, JSONArray data) {
  473 + String type = s.getType() == null ? "" : s.getType().toLowerCase();
  474 + Set<String> warnGroups = null;
  475 + if ("scenario2".equals(type)) {
  476 + warnGroups = warningSuppliers2(s, data);
  477 + } else if ("scenario4".equals(type) || "scenario5".equals(type)) {
  478 + warnGroups = warningGroupsByUp(s, data);
  479 + } else if ("scenario6".equals(type)) {
  480 + warnGroups = warningGroupsByCount(s, data);
  481 + } else if ("scenario8".equals(type) || "scenario9".equals(type)) {
  482 + warnGroups = warningGroupsByRatio(s, data);
  483 + }
  484 + List<String> values = new ArrayList<>();
  485 + for (int i = 0; i < data.size(); i++) {
  486 + JSONObject row = data.getJSONObject(i);
  487 + String v = "";
  488 + if ("scenario1".equals(type)) {
  489 + v = rowHitScenario1(s, row) ? displayName(s, row) : "";
  490 + } else if ("scenario3".equals(type)) {
  491 + v = rowHitScenario3(s, row) ? displayName(s, row) : "";
  492 + } else if (warnGroups != null) {
  493 + String g = orEmpty(row.getString(s.getGroupField()));
  494 + v = warnGroups.contains(g) ? displayName(s, row) : "";
  495 + }
  496 + values.add(v);
  497 + }
  498 + return values;
  499 + }
  500 +
  501 + private String displayName(JdyRiskProperties.Scenario s, JSONObject row) {
  502 + if (s.getExtraField() != null && !s.getExtraField().isEmpty()) {
  503 + return orEmpty(row.getString(s.getExtraField()));
  504 + }
  505 + if (s.getGroupField() != null && !s.getGroupField().isEmpty()) {
  506 + return orEmpty(row.getString(s.getGroupField()));
  507 + }
  508 + return "命中";
  509 + }
  510 +
  511 + private boolean rowHitScenario1(JdyRiskProperties.Scenario s, JSONObject row) {
  512 + double total = num(row, s.getTotalField());
  513 + double diff = num(row, s.getDiffField());
  514 + Integer flow = row.getInteger("flowState");
  515 + return total >= 5 && diff >= 1 && flow != null && flow == s.getInProgressState();
  516 + }
  517 +
  518 + private boolean rowHitScenario3(JdyRiskProperties.Scenario s, JSONObject row) {
  519 + double total = num(row, s.getTotalField());
  520 + double ratio = num(row, s.getRatioField());
  521 + return total >= 5 && ratio > 0.6;
  522 + }
  523 +
  524 + /** 升级字段是否命中:非空 且 不在排除值内。 */
  525 + private boolean isUpHit(JdyRiskProperties.Scenario s, JSONObject row) {
  526 + String up = row.getString(s.getUpField());
  527 + if (up == null || up.trim().isEmpty()) {
  528 + return false;
  529 + }
  530 + if (s.getExcludeValues() != null) {
  531 + for (String ex : s.getExcludeValues()) {
  532 + if (ex != null && ex.equalsIgnoreCase(up.trim())) {
  533 + return false;
  534 + }
  535 + }
  536 + }
  537 + return true;
  538 + }
  539 +
  540 + /* ==================== 工具 ==================== */
  541 +
  542 + /** 在启效记录中按 syncCode 匹配(重复时取第一条并告警)。 */
  543 + private JSONObject findRecordBySyncCode(JSONArray records, String syncCode) {
  544 + if (records == null || syncCode == null || syncCode.isEmpty()) {
  545 + return null;
  546 + }
  547 + JSONObject found = null;
  548 + for (int i = 0; i < records.size(); i++) {
  549 + JSONObject r = records.getJSONObject(i);
  550 + if (syncCode.equals(r.getString("syncCode"))) {
  551 + if (found == null) {
  552 + found = r;
  553 + } else {
  554 + log.warn("syncCode={} 存在多条记录(id={} 与 id={}),使用第一条",
  555 + syncCode, found.getString("id"), r.getString("id"));
  556 + }
  557 + }
  558 + }
  559 + return found;
  560 + }
  561 +
  562 + private static ZonedDateTime quarterStart(ZonedDateTime now) {
  563 + int qStartMonth = ((now.getMonthValue() - 1) / 3) * 3 + 1;
  564 + return now.withMonth(qStartMonth).withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
  565 + }
  566 +
  567 + private static ZonedDateTime parseTime(String iso) {
  568 + if (iso == null || iso.isEmpty()) {
  569 + return null;
  570 + }
  571 + try {
  572 + return Instant.parse(iso).atZone(ZONE);
  573 + } catch (Exception e) {
  574 + return null;
  575 + }
  576 + }
  577 +
  578 + private static double num(JSONObject row, String field) {
  579 + if (field == null) {
  580 + return 0d;
  581 + }
  582 + Object v = row.get(field);
  583 + if (v == null) {
  584 + return 0d;
  585 + }
  586 + if (v instanceof Number) {
  587 + return ((Number) v).doubleValue();
  588 + }
  589 + try {
  590 + return Double.parseDouble(String.valueOf(v));
  591 + } catch (NumberFormatException e) {
  592 + return 0d;
  593 + }
  594 + }
  595 +
  596 + private static String orEmpty(String s) {
  597 + return (s == null || s.isEmpty()) ? "(空)" : s;
  598 + }
  599 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.service;
  2 +
  3 +import com.alibaba.fastjson.JSONArray;
  4 +import com.alibaba.fastjson.JSONObject;
  5 +import com.qgutech.qgyun.apaas.sync.jdy.api.JiandaoYunApi;
  6 +import com.qgutech.qgyun.apaas.sync.jdy.api.QxApi;
  7 +import com.qgutech.qgyun.apaas.sync.jdy.config.JdySyncProperties;
  8 +import com.qgutech.qgyun.apaas.sync.jdy.util.JsonHttpUtils;
  9 +import lombok.extern.slf4j.Slf4j;
  10 +import org.springframework.stereotype.Service;
  11 +
  12 +import java.time.OffsetDateTime;
  13 +import java.time.format.DateTimeParseException;
  14 +import java.util.ArrayList;
  15 +import java.util.HashSet;
  16 +import java.util.LinkedHashMap;
  17 +import java.util.List;
  18 +import java.util.Map;
  19 +import java.util.Set;
  20 +
  21 +/**
  22 + * 简道云 -> 启效 同步编排:找应用 -> 列表单/字段/数据 -> 建/复用启效表单+字段 -> 写数据(镜像或追加)。
  23 + * <p>
  24 + * 依赖:仅有 HTTP 调用(简道云 V5 + 启效平台),无任何中间件,独立进程可跑。
  25 + */
  26 +@Slf4j
  27 +@Service
  28 +public class JdyToQxSyncService {
  29 +
  30 + private final JdySyncProperties props;
  31 +
  32 + public JdyToQxSyncService(JdySyncProperties props) {
  33 + this.props = props;
  34 + }
  35 +
  36 + /** 同步全部配置的应用。 */
  37 + public List<String> syncAll() {
  38 + List<String> summary = new ArrayList<>();
  39 + for (JdySyncProperties.App app : props.getApps()) {
  40 + try {
  41 + summary.add(syncApp(app));
  42 + } catch (Exception e) {
  43 + log.error("同步应用「{}」失败", app.getName(), e);
  44 + summary.add("FAIL " + app.getName() + " : " + e.getMessage());
  45 + }
  46 + }
  47 + return summary;
  48 + }
  49 +
  50 + /** 同步单个应用,返回摘要。 */
  51 + public String syncApp(JdySyncProperties.App app) {
  52 + JiandaoYunApi jdy = new JiandaoYunApi(props.getHost());
  53 + QxApi qx = new QxApi(props.getQx().getBaseUrl());
  54 + String token = qx.login(props.getQx().getUsername(), props.getQx().getPassword(), props.getQx().getCorpCode());
  55 +
  56 + String qxAppId = app.getQxAppId();
  57 + if (qxAppId == null || qxAppId.isEmpty()) {
  58 + qxAppId = qx.findAppByName(token, app.getName());
  59 + if (qxAppId == null || qxAppId.isEmpty()) {
  60 + qxAppId = qx.createApp(token, app.getName());
  61 + log.info("启效无同名应用,自动创建: {} -> {}", app.getName(), qxAppId);
  62 + } else {
  63 + log.info("启效已存在同名应用,直接复用: {} -> {}", app.getName(), qxAppId);
  64 + }
  65 + }
  66 +
  67 + String jdyAppId = app.getAppId();
  68 + if (jdyAppId == null || jdyAppId.isEmpty()) {
  69 + jdyAppId = jdy.findAppId(app.getApiKey(), app.getName());
  70 + }
  71 + if (jdyAppId == null || jdyAppId.isEmpty()) {
  72 + throw new IllegalStateException("简道云未找到应用: " + app.getName());
  73 + }
  74 +
  75 + JSONArray forms = jdy.listAllForms(app.getApiKey(), jdyAppId);
  76 + int formCount = 0;
  77 + int recordCount = 0;
  78 + for (int i = 0; i < forms.size(); i++) {
  79 + JSONObject form = forms.getJSONObject(i);
  80 + String formName = form.getString("name");
  81 + String entryId = form.getString("entry_id");
  82 + if (!app.getFormNames().isEmpty() && !app.getFormNames().contains(formName)) {
  83 + continue;
  84 + }
  85 + if (!app.getEntryIds().isEmpty() && !app.getEntryIds().contains(entryId)) {
  86 + log.info("按 entryIds 过滤,跳过表单 {} ({})", formName, entryId);
  87 + continue;
  88 + }
  89 + int cnt = syncForm(app, jdy, qx, token, qxAppId, jdyAppId, form);
  90 + formCount++;
  91 + recordCount += cnt;
  92 + }
  93 + String summary = "OK " + app.getName() + " | 表单=" + formCount + " 数据=" + recordCount;
  94 + log.info(summary);
  95 + return summary;
  96 + }
  97 +
  98 + private int syncForm(JdySyncProperties.App app, JiandaoYunApi jdy, QxApi qx, String token,
  99 + String qxAppId, String jdyAppId, JSONObject form) {
  100 + String formName = form.getString("name");
  101 + String entryId = form.getString("entry_id");
  102 +
  103 + JSONArray widgets = jdy.listWidgets(app.getApiKey(), jdyAppId, entryId);
  104 +
  105 + // 构建字段 + 映射(widget.code -> fieldKey)+ 日期字段
  106 + List<QxApi.Field> fields = new ArrayList<>();
  107 + Map<String, String> widgetToFieldKey = new LinkedHashMap<>();
  108 + Set<String> dateFieldKeys = new HashSet<>();
  109 + Set<String> usedFieldKey = new HashSet<>();
  110 + for (int i = 0; i < widgets.size(); i++) {
  111 + JSONObject w = widgets.getJSONObject(i);
  112 + String widgetCode = w.getString("name");
  113 + String label = w.getString("label");
  114 + if (label == null || label.isEmpty()) {
  115 + label = w.getString("widgetName");
  116 + }
  117 + String jdyType = w.getString("type");
  118 + String fieldKey = FieldMapping.genFieldKey(label, app.getFieldMapping(), usedFieldKey);
  119 + String qxType = FieldMapping.mapType(jdyType);
  120 + fields.add(new QxApi.Field(label, fieldKey, qxType));
  121 + widgetToFieldKey.put(widgetCode, fieldKey);
  122 + if (FieldMapping.isDateJdy(jdyType)) {
  123 + dateFieldKeys.add(fieldKey);
  124 + }
  125 + }
  126 +
  127 + // 确保启效表单存在(按「表单名+新增接口」判断)
  128 + JSONArray apiManages = qx.listApiManages(token, qxAppId);
  129 + JSONObject addApi = qx.findAddApi(apiManages, formName);
  130 + if (addApi == null) {
  131 + String funCode = FieldMapping.genFunCode(formName);
  132 + String modelId = qx.createFormModel(token, qxAppId, formName, funCode);
  133 + qx.saveDesignObj(token, qxAppId, app.getName(), modelId, formName, funCode, fields);
  134 + log.info("创建启效表单 {}: funCode={} modelId={} 字段={}", formName, funCode, modelId, buildFieldsText(fields));
  135 + apiManages = qx.listApiManages(token, qxAppId);
  136 + addApi = qx.findAddApi(apiManages, formName);
  137 + }
  138 + if (addApi == null) {
  139 + throw new IllegalStateException("未找到表单「" + formName + "」新增接口");
  140 + }
  141 + String addCode = addApi.getString("code");
  142 +
  143 + // replace 模式:先清空目标表单数据(分页查全量 -> 单次删除)
  144 + if (app.isReplace()) {
  145 + JSONObject pageApi = qx.findApiByKeyword(apiManages, formName, "分页查询");
  146 + JSONObject delApi = qx.findApiByKeyword(apiManages, formName, "删除");
  147 + if (pageApi != null && delApi != null) {
  148 + JSONArray existing = qx.pageQuery(token, qxAppId, pageApi.getString("code"));
  149 + int del = 0;
  150 + for (int i = 0; i < existing.size(); i++) {
  151 + JSONObject row = existing.getJSONObject(i);
  152 + qx.deleteData(token, qxAppId, delApi.getString("code"), row.getString("id"));
  153 + del++;
  154 + }
  155 + log.info("清空 {} 已有数据 {} 条", formName, del);
  156 + } else {
  157 + log.warn("未找到分页查询/删除接口,跳过清空: {}", formName);
  158 + }
  159 + }
  160 +
  161 + // 拉简道云数据(next_id 分页,limit 上限 100)并写入
  162 + List<JSONObject> records = fetchAll(jdy, app.getApiKey(), jdyAppId, entryId);
  163 + int ok = 0;
  164 + int fail = 0;
  165 + for (JSONObject rec : records) {
  166 + try {
  167 + Map<String, Object> payload = buildPayload(rec, widgets, widgetToFieldKey, dateFieldKeys);
  168 + qx.insertData(token, qxAppId, addCode, payload);
  169 + ok++;
  170 + } catch (Exception e) {
  171 + fail++;
  172 + log.error("写入单条失败({}),继续下一条: {}", formName, e.getMessage());
  173 + }
  174 + }
  175 + log.info("同步 {}: 写入 {} 条, 失败 {} 条", formName, ok, fail);
  176 + return ok;
  177 + }
  178 +
  179 + /** 全量拉取数据:v5 不返回 next_id,用上一页最后一条 _id 作为 data_id 继续翻页,直到不足一页。 */
  180 + private List<JSONObject> fetchAll(JiandaoYunApi jdy, String apiKey, String appId, String entryId) {
  181 + List<JSONObject> result = new ArrayList<>();
  182 + String dataId = null;
  183 + int page = 0;
  184 + do {
  185 + page++;
  186 + Map<String, Object> body = new LinkedHashMap<>();
  187 + body.put("app_id", appId);
  188 + body.put("entry_id", entryId);
  189 + body.put("limit", 100);
  190 + if (dataId != null && !dataId.isEmpty()) {
  191 + body.put("data_id", dataId);
  192 + }
  193 + JSONObject resp = JsonHttpUtils.postJson2Obj(jdy.getHost() + "/v5/app/entry/data/list",
  194 + JsonHttpUtils.bearer(apiKey), body);
  195 + checkJdyError(resp);
  196 + JSONArray data = resp.getJSONArray("data");
  197 + int size = data == null ? 0 : data.size();
  198 + if (data != null) {
  199 + for (int i = 0; i < size; i++) {
  200 + result.add(data.getJSONObject(i));
  201 + }
  202 + }
  203 + dataId = size > 0 ? data.getJSONObject(size - 1).getString("_id") : null;
  204 + log.info("分页第 {} 页, 本页 {} 条, data_id={}", page, size, dataId);
  205 + if (size < 100) {
  206 + break;
  207 + }
  208 + } while (dataId != null && !dataId.isEmpty());
  209 + return result;
  210 + }
  211 +
  212 + /** 校验简道云返回错误码,失败并带说明抛出。 */
  213 + private void checkJdyError(JSONObject resp) {
  214 + if (resp == null) {
  215 + throw new IllegalStateException("简道云返回为空");
  216 + }
  217 + Integer code = resp.getInteger("code");
  218 + if (code != null && !Integer.valueOf(0).equals(code)) {
  219 + throw new IllegalStateException("简道云 error code=" + code + " msg=" + resp.getString("msg")
  220 + + (resp.getJSONObject("meta") == null ? "" : " ip=" + resp.getJSONObject("meta").getString("ip")));
  221 + }
  222 + }
  223 +
  224 + private Map<String, Object> buildPayload(JSONObject record, JSONArray widgets,
  225 + Map<String, String> widgetToFieldKey, Set<String> dateFieldKeys) {
  226 + Map<String, Object> payload = new LinkedHashMap<>();
  227 + for (int i = 0; i < widgets.size(); i++) {
  228 + JSONObject w = widgets.getJSONObject(i);
  229 + String widgetCode = w.getString("name");
  230 + String fieldKey = widgetToFieldKey.get(widgetCode);
  231 + if (fieldKey == null) {
  232 + continue;
  233 + }
  234 + Object v = record.get(widgetCode);
  235 + if (dateFieldKeys.contains(fieldKey) && v instanceof String) {
  236 + v = toMs((String) v);
  237 + }
  238 + payload.put(fieldKey, v);
  239 + }
  240 + return payload;
  241 + }
  242 +
  243 + private String buildFieldsText(List<QxApi.Field> fields) {
  244 + StringBuilder sb = new StringBuilder();
  245 + for (QxApi.Field f : fields) {
  246 + if (sb.length() > 0) {
  247 + sb.append(", ");
  248 + }
  249 + sb.append(f.getTitle()).append("->").append(f.getFieldKey());
  250 + }
  251 + return sb.toString();
  252 + }
  253 +
  254 + public static Object toMs(String iso) {
  255 + try {
  256 + return OffsetDateTime.parse(iso).toInstant().toEpochMilli();
  257 + } catch (DateTimeParseException e) {
  258 + try {
  259 + return OffsetDateTime.parse(iso.replace(" ", "T")).toInstant().toEpochMilli();
  260 + } catch (Exception ex) {
  261 + return iso;
  262 + }
  263 + }
  264 + }
  265 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.task;
  2 +
  3 +import com.qgutech.qgyun.apaas.sync.jdy.config.JdySyncProperties;
  4 +import com.qgutech.qgyun.apaas.sync.jdy.service.JdyToQxSyncService;
  5 +import lombok.extern.slf4j.Slf4j;
  6 +import org.springframework.scheduling.annotation.Scheduled;
  7 +import org.springframework.stereotype.Component;
  8 +
  9 +import javax.annotation.Resource;
  10 +import java.util.List;
  11 +
  12 +/**
  13 + * 简道云 -> 启效 每日定时同步任务。
  14 + */
  15 +@Slf4j
  16 +@Component
  17 +public class JdySyncScheduledTask {
  18 +
  19 + @Resource
  20 + private JdyToQxSyncService syncService;
  21 +
  22 + @Resource
  23 + private JdySyncProperties props;
  24 +
  25 + @Scheduled(cron = "${jdy-sync.cron:0 0 2 * * ?}")
  26 + public void dailySync() {
  27 + if (!props.isEnabled()) {
  28 + log.info("jdy-sync.enabled=false,跳过每日同步");
  29 + return;
  30 + }
  31 + log.info("开始简道云每日同步,应用数: {}", props.getApps().size());
  32 + List<String> result = syncService.syncAll();
  33 + result.forEach(r -> log.info("同步结果: {}", r));
  34 + }
  35 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.util;
  2 +
  3 +import com.alibaba.fastjson.JSON;
  4 +import com.alibaba.fastjson.JSONObject;
  5 +import lombok.extern.slf4j.Slf4j;
  6 +import org.apache.http.HttpEntity;
  7 +import org.apache.http.HttpStatus;
  8 +import org.apache.http.client.methods.CloseableHttpResponse;
  9 +import org.apache.http.client.methods.HttpGet;
  10 +import org.apache.http.client.methods.HttpPost;
  11 +import org.apache.http.client.methods.HttpUriRequest;
  12 +import org.apache.http.entity.ContentType;
  13 +import org.apache.http.entity.StringEntity;
  14 +import org.apache.http.impl.client.CloseableHttpClient;
  15 +import org.apache.http.util.EntityUtils;
  16 +
  17 +import java.util.HashMap;
  18 +import java.util.Map;
  19 +
  20 +/**
  21 + * JSON HTTP 工具类。
  22 + * <p>
  23 + * 关键:启效(dev)网关对无 {@code User-Agent} 的请求返回 500,因此所有请求都强制带 UA。
  24 + */
  25 +@Slf4j
  26 +public class JsonHttpUtils {
  27 +
  28 + public static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) qx-apaas-sync";
  29 +
  30 + private JsonHttpUtils() {
  31 + }
  32 +
  33 + public static CloseableHttpClient client() {
  34 + try {
  35 + return SSLClientCustom.getHttpClinet();
  36 + } catch (Exception e) {
  37 + throw new RuntimeException("创建 http client 失败", e);
  38 + }
  39 + }
  40 +
  41 + /**
  42 + * POST JSON,返回响应 body 字符串。
  43 + */
  44 + public static String postJson(String url, Map<String, String> headers, String jsonBody) {
  45 + HttpPost post = new HttpPost(url);
  46 + for (Map.Entry<String, String> e : headers.entrySet()) {
  47 + post.setHeader(e.getKey(), e.getValue());
  48 + }
  49 + post.setHeader("User-Agent", USER_AGENT);
  50 + post.setHeader("Content-Type", "application/json;charset=UTF-8");
  51 + if (jsonBody != null) {
  52 + post.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON));
  53 + }
  54 + return execute(post);
  55 + }
  56 +
  57 + /**
  58 + * POST JSON,解析为 JSONObject。
  59 + */
  60 + public static JSONObject postJson2Obj(String url, Map<String, String> headers, Object jsonBody) {
  61 + String body = postJson(url, headers, jsonBody == null ? null : JSON.toJSONString(jsonBody));
  62 + return parse(body);
  63 + }
  64 +
  65 + public static String get(String url, Map<String, String> headers) {
  66 + HttpGet get = new HttpGet(url);
  67 + for (Map.Entry<String, String> e : headers.entrySet()) {
  68 + get.setHeader(e.getKey(), e.getValue());
  69 + }
  70 + get.setHeader("User-Agent", USER_AGENT);
  71 + return execute(get);
  72 + }
  73 +
  74 + /**
  75 + * GET JSON,解析为 JSONObject。
  76 + */
  77 + public static JSONObject getJson2Obj(String url, Map<String, String> headers) {
  78 + return parse(get(url, headers));
  79 + }
  80 +
  81 + public static JSONObject parse(String body) {
  82 + if (body == null || body.isEmpty()) {
  83 + return new JSONObject();
  84 + }
  85 + try {
  86 + return JSON.parseObject(body);
  87 + } catch (Exception e) {
  88 + log.warn("非 JSON 响应: {}", body);
  89 + JSONObject o = new JSONObject();
  90 + o.put("_raw", body);
  91 + return o;
  92 + }
  93 + }
  94 +
  95 + private static String execute(HttpUriRequest request) {
  96 + return run(request);
  97 + }
  98 +
  99 + /** 执行请求并返回响应体(公共,供 multipart 等场景复用)。 */
  100 + public static String run(HttpUriRequest request) {
  101 + CloseableHttpClient client = client();
  102 + try (CloseableHttpResponse resp = client.execute(request)) {
  103 + int code = resp.getStatusLine().getStatusCode();
  104 + HttpEntity entity = resp.getEntity();
  105 + String body = entity == null ? "" : EntityUtils.toString(entity);
  106 + if (code != HttpStatus.SC_OK) {
  107 + log.warn("HTTP {} -> {} : {}", request.getMethod(), request.getURI(), body);
  108 + }
  109 + return body;
  110 + } catch (Exception e) {
  111 + throw new RuntimeException("请求失败: " + request.getURI() + " -> " + e.getMessage(), e);
  112 + } finally {
  113 + try {
  114 + client.close();
  115 + } catch (Exception ignored) {
  116 + // ignore
  117 + }
  118 + }
  119 + }
  120 +
  121 + public static Map<String, String> bearer(String token) {
  122 + Map<String, String> m = new HashMap<>();
  123 + m.put("Authorization", "Bearer " + token);
  124 + return m;
  125 + }
  126 +
  127 + public static Map<String, String> token(String token) {
  128 + Map<String, String> m = new HashMap<>();
  129 + m.put("Authorization", token);
  130 + return m;
  131 + }
  132 +}
... ...
  1 +package com.qgutech.qgyun.apaas.sync.jdy.util;
  2 +
  3 +import org.apache.http.config.Registry;
  4 +import org.apache.http.config.RegistryBuilder;
  5 +import org.apache.http.conn.socket.ConnectionSocketFactory;
  6 +import org.apache.http.conn.socket.PlainConnectionSocketFactory;
  7 +import org.apache.http.conn.ssl.NoopHostnameVerifier;
  8 +import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
  9 +import org.apache.http.conn.ssl.TrustStrategy;
  10 +import org.apache.http.impl.client.CloseableHttpClient;
  11 +import org.apache.http.impl.client.HttpClients;
  12 +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
  13 +import org.apache.http.ssl.SSLContextBuilder;
  14 +
  15 +import java.security.KeyManagementException;
  16 +import java.security.KeyStoreException;
  17 +import java.security.NoSuchAlgorithmException;
  18 +import java.security.cert.CertificateException;
  19 +import java.security.cert.X509Certificate;
  20 +
  21 +/**
  22 + * SSL 客户端工厂(信任所有站点,兼容自定义证书域名)。
  23 + */
  24 +public class SSLClientCustom {
  25 +
  26 + private static final String HTTP = "http";
  27 + private static final String HTTPS = "https";
  28 + private static SSLConnectionSocketFactory sslConnectionSocketFactory = null;
  29 + //连接池管理类
  30 + private static PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = null;
  31 + //管理Https连接的上下文类
  32 + private static SSLContextBuilder sslContextBuilder = null;
  33 +
  34 + static {
  35 + try {
  36 + sslContextBuilder = new SSLContextBuilder().loadTrustMaterial(null, new TrustStrategy() {
  37 + @Override
  38 + public boolean isTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
  39 +// 信任所有站点 直接返回true
  40 + return true;
  41 + }
  42 + });
  43 + sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContextBuilder.build(),
  44 + new String[]{"TLSv1", "TLSv1.1", "TLSv1.2"}, null, NoopHostnameVerifier.INSTANCE);
  45 + Registry<ConnectionSocketFactory> registryBuilder = RegistryBuilder.<ConnectionSocketFactory>create()
  46 + .register(HTTP, new PlainConnectionSocketFactory())
  47 + .register(HTTPS, sslConnectionSocketFactory)
  48 + .build();
  49 + poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(registryBuilder);
  50 + poolingHttpClientConnectionManager.setMaxTotal(200);
  51 + } catch (NoSuchAlgorithmException e) {
  52 + e.printStackTrace();
  53 + } catch (KeyStoreException e) {
  54 + e.printStackTrace();
  55 + } catch (KeyManagementException e) {
  56 + e.printStackTrace();
  57 + }
  58 + }
  59 +
  60 + /**
  61 + * 获取连接
  62 + *
  63 + * @return
  64 + * @throws Exception
  65 + */
  66 + public static CloseableHttpClient getHttpClinet() throws Exception {
  67 + CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(sslConnectionSocketFactory)
  68 + .setConnectionManager(poolingHttpClientConnectionManager)
  69 + .setConnectionManagerShared(true)
  70 + .build();
  71 + return httpClient;
  72 + }
  73 +}
... ...
  1 +server:
  2 + port: 8090
  3 +
  4 +spring:
  5 + application:
  6 + name: qx-apaas-sync
  7 +
  8 +logging:
  9 + path: ./logs
  10 + level:
  11 + com.qgutech.qgyun.apaas.sync: INFO
  12 +
  13 +# 简道云 -> 启效 同步
  14 +jdy-sync:
  15 + enabled: false # true=启用每日定时任务;false=仅通过 REST 接口手动触发
  16 + cron: "0 0 2 * * ?" # 每天 02:00
  17 + # 简道云基础地址:独立域名/独享版用 https://<域名>/api(长安: https://jdy.changan.com.cn/api);公有云用 https://api.jiandaoyun.com/api
  18 + host: https://jdy.changan.com.cn/api
  19 + qx:
  20 + base-url: https://dev3.qgutech.com # 启效3.0 环境地址
  21 + username: 15077901670
  22 + password: "000000"
  23 + corp-code: default
  24 + apps:
  25 + - name: 合肥长安_技术处 # 简道云应用名
  26 + app-id: 65fd5c0e0d1f1a7ae4f038ec # 简道云 app_id(今日实测)
  27 + api-key: lOnr4ms8jfc0gbCwVYS7YtbYQXjBFq979Cc06BcC866427b2a7aAd054598Bc7bB
  28 + entry-ids: # 仅同步这些表单 entry_id(实测可访问)
  29 + - 696f1754f141344a6b794f9c
  30 + qx-app-id: # 目标启效应用id(不填则按 name 自动创建;已存在同名应用会直接复用)
  31 + replace: true # 是=每天清空目标表单后重建镜像;否=追加
  32 + # field-mapping: {姓名: name, 姓名: name} # 可选,覆盖内置字典
  33 + # form-names: [人员管理, 部门管理] # 可选,仅同步这些表单
  34 +
... ...