Showing
21 changed files
with
4160 additions
and
0 deletions
application.properties
0 → 100644
| 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 | + |
pom.xml
0 → 100644
| 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:全量,满足 总计>=5 且 差值>=1 且 flowState=进行中 的行数</li> | ||
| 168 | + * <li>scenario2:当月,按供应商分组,SI问题数量/问题数量合计>60% 且 问题数量合计>=5 的供应商个数</li> | ||
| 169 | + * <li>scenario3:当月,SI占比>60% 且 总问题数>=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 | +} |
src/main/resources/application.yml
0 → 100644
| 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 | + |