Commit fbd8d21a authored by shanglipeng's avatar shanglipeng

init

parent 274ca314
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.greatchn</groupId>
<artifactId>testspider</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.seleniumhq.selenium/selenium-java -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.8.0</version>
<exclusions>
<exclusion>
<artifactId>guava</artifactId>
<groupId>com.google.guava</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/net.lightbody.bmp/browsermob-core -->
<dependency>
<groupId>net.lightbody.bmp</groupId>
<artifactId>browsermob-core</artifactId>
<version>2.1.5</version>
<exclusions>
<exclusion>
<artifactId>guava</artifactId>
<groupId>com.google.guava</groupId>
</exclusion>
<exclusion>
<artifactId>jackson-annotations</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
</exclusion>
<exclusion>
<artifactId>jackson-core</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
</exclusion>
<exclusion>
<artifactId>jackson-databind</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.14.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-annotations -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.14.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.14.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jdk8 -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
<version>2.14.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jsr310 -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.14.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.module/jackson-module-parameter-names -->
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-parameter-names</artifactId>
<version>2.14.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.yaml/snakeyaml -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.33</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.11</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-api -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.6</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.5</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.26</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.zaxxer/HikariCP -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.0.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpmime -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<version>1.18.22</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>13.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
\ No newline at end of file
package com.greatchn;
import com.greatchn.yzh.TaskExecutor;
/**
* RPA With Selenium for Java 入口
*
* @author LWang 2023.01.28
* @since 1.0.0
*/
public class Main {
public static void main(String[] args) {
new TaskExecutor();
}
}
\ No newline at end of file
package com.greatchn.etax;
import cn.hutool.core.lang.ClassScanner;
import com.greatchn.etax.exceptions.EtaxException;
import com.greatchn.rpa.RpaCore;
import com.greatchn.rpa.config.RpaConfig;
import java.lang.reflect.Modifier;
import java.util.LinkedHashMap;
import java.util.Objects;
/**
* 电子税务局业务对象基础类
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
public abstract class BaseEtax {
private final EtaxConfig config;
private final RpaCore rpa;
private final LinkedHashMap<Class<? extends BaseFunction>, BaseFunction> functions;
protected static final String[] ROLES_TAX = {"法人", "法定代表人", "财务负责人", "办税员"};
protected static final String[] ROLES_INVOICE = {"法人", "法定代表人", "财务负责人", "开票员"};
/**
* 停用服务
*/
public void shutdown() {
if (Objects.isNull(rpa)) {
return;
}
rpa.stop();
}
/**
* 构造电子税务局业务对象
*
* @param config RPA 配置
* @param etaxConfigFile 电子税务局配置
*/
protected BaseEtax(RpaConfig config, String etaxConfigFile) {
this.config = EtaxConfig.build(etaxConfigFile);
this.rpa = new RpaCore(config);
this.functions = loadFunctions(this.getClass().getPackage());
}
/**
* 在指定包下,扫描 BaseFunction 的子类,并进行初始化
*
* @param pg 要扫描的包
* @return 扫描结果
*/
public LinkedHashMap<Class<? extends BaseFunction>, BaseFunction> loadFunctions(Package pg) {
final var result = new LinkedHashMap<Class<? extends BaseFunction>, BaseFunction>();
ClassScanner.scanPackage(pg.getName(), BaseFunction.class::isAssignableFrom).forEach(c -> {
try {
if (!Modifier.isAbstract(c.getModifiers())) {
result.put(
(Class<BaseFunction>) c,
(BaseFunction) c.getConstructor(BaseEtax.class).newInstance(this)
);
}
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
});
return result;
}
protected EtaxConfig config() {
return this.config;
}
protected RpaCore rpa() {
return this.rpa;
}
protected LinkedHashMap<Class<? extends BaseFunction>, BaseFunction> functions() {
return this.functions;
}
public <T extends BaseFunction> T function(Class<T> functionClass) {
var function = this.functions.get(functionClass);
if (functionClass.isInstance(function)) {
return (T) this.functions.get(functionClass);
} else {
throw new EtaxException(functionClass.getName() + " 方法对象不存在!");
}
}
private String[] roles = ROLES_TAX;
/**
* 角色类型:办税角色
*/
public final static int ROLE_TYPE_TAX = 0;
/**
* 角色类型:发票角色
*/
public final static int ROLE_TYPE_INVOICE = 1;
/**
* 设置角色
*
* @param roleType 角色类型:0:办税;1:发票
*/
protected void setRole(int roleType) {
this.roles = ROLE_TYPE_TAX == roleType ? ROLES_TAX : ROLES_INVOICE;
}
/**
* 获取当前任务所需的角色列表
*
* @return 角色列表
*/
public String[] getRoles() {
return this.roles;
}
}
package com.greatchn.etax;
import com.greatchn.etax.exceptions.EtaxException;
import com.greatchn.etax.exceptions.RetryWarning;
import com.greatchn.etax.sms.exceptions.SmsException;
import com.greatchn.kits.DateTimeKits;
import com.greatchn.rpa.RpaCore;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.NoSuchElementException;
import java.time.temporal.ChronoUnit;
/**
* 方法基础类
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
@Slf4j
public abstract class BaseFunction {
private final BaseEtax etax;
private final EtaxConfig config;
private final RpaCore rpa;
protected static final int SLEEP_TIME = 3;
protected static final long WAIT_TIME = 10;
protected static final long MOUSE_OFFSET = 50;
protected BaseEtax etax() {
return this.etax;
}
protected EtaxConfig config() {
return this.config;
}
protected RpaCore rpa() {
return this.rpa;
}
public BaseFunction(BaseEtax etax) {
this.etax = etax;
this.config = etax.config();
this.rpa = etax.rpa();
}
/**
* 统一异常处理
*
* @param cause 要处理的异常
* @param messagePrefix 消息前缀
* @return 异常消息描述
*/
protected <T> FunctionResult<T> handleException(Throwable cause, String messagePrefix) {
// 1. 处理需要单独处理的异常
if (cause instanceof NoSuchElementException e) {
return new FunctionResult<>(false, messagePrefix + e.getMessage(), null);
} else if (cause instanceof EtaxException e) {
return new FunctionResult<>(false, messagePrefix + e.getMessage(), null);
} else if (cause instanceof SmsException e) {
switch (e.getFlag()) {
case SmsException.PHONE_LOCK -> throw new RetryWarning(5, e.getMessage());
case SmsException.PHONE_LIMIT_RANGE -> throw new RetryWarning(15, e.getMessage());
case SmsException.PHONE_FULL_DAY ->
throw new RetryWarning(Math.abs(DateTimeKits.timeRemaining(ChronoUnit.MINUTES)), e.getMessage());
}
} else if (cause instanceof RetryWarning e) {
throw e;
}
// 2. 其他异常使用 messagePrefix 包裹后,返回失败执行结果
log.error("发生未知的异常,", cause);
return new FunctionResult<>(false, messagePrefix + "发生未知异常!", null);
}
}
package com.greatchn.etax;
import com.greatchn.kits.YamlKits;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
/**
* 电子税务局访问配置文件
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
@ToString
@NoArgsConstructor
public class EtaxConfig {
/**
* 电子税务局域名(URL,完整域名,如:<a href="https://www.example.com/">https://www.example.com</a>)
*/
@Setter
@Getter
private String domain;
/**
* 企业电子税务局主页(URL,含完整域名,如:<a href="https://www.example.com/index.html">https://www.example.com/index.html</a>)
*/
@Setter
@Getter
private String enterpriseMainUrl;
/**
* 自然人电子税务局主页(URL,含完整域名,如:<a href="">https://www.example.com/index.html</a>)
*/
@Setter
@Getter
private String humanMainUrl;
/**
* 企业电子税务局主页(URI,不含完整域名,如:/index.html)
*/
@Setter
@Getter
private String enterpriseHomeUri;
/**
* 自然人电子税务局主页(URI,不含完整域名,如:/index.html)
*/
@Setter
@Getter
private String humanHomeUri;
public String getMainUrl() {
return LOGIN_TYPE_ENTERPRISE == loginType ? enterpriseMainUrl : humanMainUrl;
}
public String getHomeUri() {
return LOGIN_TYPE_ENTERPRISE == loginType ? enterpriseHomeUri : humanHomeUri;
}
public static final int LOGIN_TYPE_ENTERPRISE = 0;
public static final int LOGIN_TYPE_HUMAN = 1;
/**
* 登录用户类型:0,企业;1,自然人
*/
@Getter
@Setter
private int loginType = 0;
/**
* 创建电子税务局访问配置文件
*
* @param config 配置文件
* @return 配置对象
*/
public static EtaxConfig build(String config) {
return YamlKits.load(config, EtaxConfig.class);
}
}
package com.greatchn.etax;
/**
* 电子税务局方法执行结果
*
* @param success
* @param message
* @param result
* @param <T>
*/
public record FunctionResult<T>(boolean success, String message, T result) {
}
package com.greatchn.etax.exceptions;
/**
* EtaxException
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
public class EtaxException extends RuntimeException {
public EtaxException(String message) {
super(message);
}
public EtaxException(String message, Throwable cause) {
super(message, cause);
}
}
package com.greatchn.etax.exceptions;
/**
* 重试建议异常
*
* @author LWang 2023.02.01
* @since 1.0.0
*/
public class RetryWarning extends RuntimeException {
/**
*
*/
private final long retryDelay;
/**
* 重试警告异常
*
* @param retryDelay 重试延时(单位:分钟)
* @param message 异常消息
*/
public RetryWarning(long retryDelay, String message) {
super(message);
this.retryDelay = retryDelay;
}
public long getRetryDelay() {
return retryDelay;
}
}
package com.greatchn.etax.monitor;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.RandomUtils;
import org.apache.commons.lang3.StringUtils;
import javax.swing.*;
import java.awt.*;
import java.util.concurrent.TimeUnit;
/**
* 任务监视器
*
* @author LWang 2023.02.03
* @since 1.0.0
*/
public class Monitor extends JFrame {
// 内部组件
/**
* 运行实例 ID Label
*/
private JLabel instanceLabel;
/**
* 用户 ID Label
*/
private JLabel userLabel;
/**
* 任务 ID Label
*/
private JLabel taskLabel;
/**
* 日志 label
*/
private JLabel logLabel;
/**
* 关闭按钮
*/
private JButton closeButton;
private final int width = 960;
private final int height = 64;
/**
* 任务监视器启动服务
*/
public Monitor() {
JFrame.setDefaultLookAndFeelDecorated(false);
this.setLayout(null);
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
this.setPreferredSize(new Dimension(width, height));
this.setUndecorated(true);
this.getRootPane().setWindowDecorationStyle(JRootPane.NONE);
this.setLocation(0, 500);
this.setAlwaysOnTop(true);
this.addComponent();
this.pack();
this.setVisible(true);
}
private final String instanceLabelTextPatter = "当前实例编号:%s";
private final String userLabelTextPatter = "当前任务用户:%s";
private final String taskLabelTextPatter = "当前任务 ID:%s";
private final String logLabelTextPatter = "任务日志:%s";
private void addComponent() {
final var startX = 10;
final var startY = 5;
final var padding = 5;
final var font = new Font("Microsoft YaHei UI", Font.PLAIN, 12);
// 使用绝对定位布局
this.setBounds(0, 0, width, height);
var x = startX;
var y = startY;
this.getContentPane().setBackground(Color.getHSBColor(0.5000f, 0.1667f, 0.4706f));
// 第一行:
this.instanceLabel = new JLabel(instanceLabelTextPatter.formatted(StringUtils.EMPTY));
this.instanceLabel.setBounds(x, y, 320, 22);
this.instanceLabel.setForeground(Color.WHITE);
this.instanceLabel.setFont(font);
this.getContentPane().add(this.instanceLabel);
x += this.instanceLabel.getWidth() + padding;
this.userLabel = new JLabel(userLabelTextPatter.formatted(StringUtils.EMPTY));
this.userLabel.setBounds(x, y, 240, 22);
this.userLabel.setForeground(Color.WHITE);
this.userLabel.setFont(font);
this.getContentPane().add(this.userLabel);
x += this.userLabel.getWidth() + padding;
this.taskLabel = new JLabel(taskLabelTextPatter.formatted(StringUtils.EMPTY));
this.taskLabel.setBounds(x, y, 240, 22);
this.taskLabel.setForeground(Color.WHITE);
this.taskLabel.setFont(font);
this.getContentPane().add(this.taskLabel);
x += this.taskLabel.getWidth() + padding * 2;
this.closeButton = new JButton("关闭服务");
this.closeButton.setBounds(x, y, 120, 54);
this.closeButton.setFont(font);
this.closeButton.setBackground(Color.getHSBColor(0.5986f, 0.5506f, 0.3490f));
this.closeButton.setForeground(Color.WHITE);
this.closeButton.setDefaultCapable(false);
this.closeButton.setBorderPainted(false);
this.closeButton.setFocusPainted(false);
this.closeButton.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
this.closeButton.addActionListener(e -> exit());
this.getContentPane().add(this.closeButton);
x = startX;
y += 32;
this.logLabel = new JLabel(logLabelTextPatter.formatted(StringUtils.EMPTY));
this.logLabel.setBounds(x, y, 810, 22);
this.logLabel.setForeground(Color.WHITE);
this.logLabel.setFont(font);
this.getContentPane().add(this.logLabel);
}
private boolean doNext = true;
public boolean doNext() {
return this.doNext;
}
/**
* 退出处理
*/
public void exit() {
this.doNext = false;
this.dispose();
}
/**
* @param userId 用户 ID
*/
public void setUserId(String userId) {
userLabel.setText(userLabelTextPatter.formatted(userId));
}
/**
* @param taskId 任务 ID
*/
public void setTaskId(String taskId) {
taskLabel.setText(taskLabelTextPatter.formatted(taskId));
}
/**
* 设置日志
*
* @param log 日志信息
*/
public void setLog(String log) {
logLabel.setText(logLabelTextPatter.formatted(log));
}
public static void main(String[] args) throws InterruptedException {
var monitor = new Monitor();
while (monitor.doNext()) {
monitor.setTaskId(String.valueOf(RandomUtils.nextLong(0, 10000)));
monitor.setUserId(RandomStringUtils.random(24, "甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳无为申酉戌亥"));
monitor.setLog(RandomStringUtils.random(100, "甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳无为申酉戌亥"));
TimeUnit.SECONDS.sleep(2);
}
System.exit(0);
}
}
package com.greatchn.etax.sms;
import com.greatchn.etax.sms.bean.SmsResult;
import com.greatchn.etax.sms.exceptions.SmsException;
import com.greatchn.kits.HttpTool;
import com.greatchn.kits.JacksonKits;
import com.greatchn.kits.YamlKits;
import lombok.Data;
import org.apache.commons.lang3.tuple.Pair;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
/**
* 短信管理服务
*
* @author LWang 2023.01.30
* @since 1.0.0
*/
public final class SmsBiz {
private SmsBiz() {
}
/**
* 短信处理配置类
*
* @author LWang 2023.02.01
* @since 1.0.0
*/
@Data
public static class SmsConfig {
/**
* 短信服务地址
*/
private String url;
}
/**
* 短信发送处理接口
*/
public interface SendShortMessageExecutor {
/**
* 发送短信功能
*/
void execute();
}
/**
* 短信类型枚举
*
* @author LWang 2023.01.30
* @since 1.0.0
*/
public enum SmsTaskType {
/**
* 天津市电子税务局验证码短信
*/
ETAX_TIANJIN("TJ_ETAX"),
/**
* 测试类型的短信
*/
TEST("TEST");
final String type;
public String getType() {
return type;
}
SmsTaskType(String type) {
this.type = type;
}
}
private final static SmsConfig CONFIG;
private final static HttpTool HTTP_TOOL;
static {
CONFIG = YamlKits.load("sms.yaml", SmsConfig.class);
try {
HTTP_TOOL = HttpTool.build();
} catch (NoSuchAlgorithmException | KeyManagementException e) {
throw new RuntimeException(e);
}
}
/**
* 添加读取短信任务
*
* @param phone 手机号
* @return 添加结果
*/
private static SmsResult addTask(String phone, SmsTaskType type) throws IOException, InterruptedException {
var url = String.format("%s/monitor_task/add", CONFIG.url);
var response = HTTP_TOOL.post(url, new ArrayList<>() {{
this.add(Pair.of("data", String.format("""
{
"phone": "%s",
"taskType": "%s"
}
""", phone, type.getType()
)
));
}});
if (response.getKey() != 200) {
return new SmsResult(SmsResult.UNKNOWN, "网络错误!", null);
}
return JacksonKits.toBean(response.getValue(), SmsResult.class);
}
/**
* 读取短信任务结果
*
* @param taskId 任务 ID
* @return 读取结果
*/
private static SmsResult readTask(String taskId) throws IOException, InterruptedException {
var url = String.format("%s/monitor_task/query", CONFIG.url);
var response = HTTP_TOOL.post(url, new ArrayList<>() {{
this.add(Pair.of("task_id", taskId));
}});
if (response.getKey() != 200) {
return new SmsResult(SmsResult.UNKNOWN, "网络错误!", null);
}
return JacksonKits.toBean(response.getValue(), SmsResult.class);
}
/**
* 取消短信任务
*
* @param taskId 任务 ID
* @return 读取结果
*/
private static SmsResult cancelTask(String taskId) throws IOException, InterruptedException {
var url = String.format("%s/monitor_task/cancel", CONFIG.url);
var response = HTTP_TOOL.post(url, new ArrayList<>() {{
this.add(Pair.of("task_id", taskId));
}});
if (response.getKey() != 200) {
return new SmsResult(SmsResult.UNKNOWN, "网络错误!", null);
}
return JacksonKits.toBean(response.getValue(), SmsResult.class);
}
/**
* 等待验证码
*
* @param phone 手机号
* @param type 验证码类型
* @param executor 执行器
* @return 验证码
*/
public static String waitSmsCode(String phone, SmsTaskType type, SendShortMessageExecutor executor) {
try {
var taskId = SmsResult.handle(addTask(phone, type)).data().taskId();
try {
executor.execute();
var queryResult = SmsResult.handle(readTask(taskId));
var retryTimes = 12;
while (queryResult.code() == SmsResult.PROCESSING) {
queryResult = SmsResult.handle(readTask(taskId));
if (retryTimes-- <= 0) {
throw new SmsException(SmsException.NET_ERROR, "获取验证码超时!");
}
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException ignore) {
}
}
return queryResult.data().validateCode();
} finally {
cancelTask(taskId);
}
} catch (IOException | InterruptedException e) {
throw new SmsException(SmsException.NET_ERROR, "短信服务异常,任务无法继续!");
}
}
public static void main(String[] args) {
waitSmsCode("18920961634", SmsTaskType.TEST, () -> System.out.println("发短信喽……"));
}
}
package com.greatchn.etax.sms.bean;
import com.fasterxml.jackson.annotation.JsonProperty;
public record SmsCode(
String taskId,
@JsonProperty("SMSContent")
String smsContent,
String validateCode,
String phone
) {
}
package com.greatchn.etax.sms.bean;
import com.greatchn.etax.sms.exceptions.SmsException;
import java.util.Objects;
/**
* 短信服务响应码
*
* @author LWang 2023.02.01
* @since 1.0.0
*/
public record SmsResult(
int code,
String message,
SmsCode data
) {
/**
* 响应码:成功 {@value }
*/
public final static int SUCCESS = 200;
/**
* 响应码:执行中 {@value }
*/
public final static int PROCESSING = 201;
/**
* 响应码:任务重复 {@value }
*/
public final static int REPEAT = 401;
/**
* 响应码:超过每日限额 {@value }
*/
public final static int LIMIT = 402;
/**
* 响应码:任务不存在 {@value }
*/
public final static int NONEXISTENT = 404;
/**
* 响应码未知异常 {@value }
*/
public final static int UNKNOWN = 499;
public static SmsResult handle(SmsResult result) {
if (Objects.isNull(result)) {
throw new IllegalArgumentException("缺少参数:result");
}
switch (result.code) {
case REPEAT, NONEXISTENT -> throw new SmsException(SmsException.PHONE_LOCK, result.message);
case LIMIT -> throw new SmsException(SmsException.PHONE_LIMIT_RANGE, result.message);
case UNKNOWN -> throw new SmsException(SmsException.NET_ERROR, result.message);
}
return result;
}
}
package com.greatchn.etax.sms.exceptions;
/**
* 短信异常
*
* @author LWang 2023.01.30
* @since 1.0.0
*/
public class SmsException extends RuntimeException {
/**
* 短信异常,手机属于锁定状态(建议任务延迟 5 分钟)
*/
public static final int PHONE_LOCK = 1;
/**
* 短信异常,电子税务局时间范围内不可再接收短信(建议任务延迟 15 分钟)
*/
public static final int PHONE_LIMIT_RANGE = 2;
/**
* 短信异常,电子税务局担心当日可用量已满(建议任务延迟到转日)
*/
public static final int PHONE_FULL_DAY = 3;
/**
* 短信异常,网络错误(建议任务延迟 5 分钟)
*/
public static final int NET_ERROR = 0;
private final int flag;
public SmsException(int flag, String message) {
super(message);
this.flag = flag;
}
/**
* 获取异常标志
*
* @return 异常标志
*/
public int getFlag() {
return flag;
}
}
package com.greatchn.etax.tianjin;
import com.greatchn.etax.BaseEtax;
import com.greatchn.etax.EtaxConfig;
import com.greatchn.etax.FunctionResult;
import com.greatchn.etax.exceptions.EtaxException;
import com.greatchn.etax.tianjin.bean.SysApiResponse;
import com.greatchn.kits.RandomKits;
import com.greatchn.rpa.beans.Point;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.openqa.selenium.By;
/**
* 自然人登录(TODO 后期完善)
*
* @author LWang 2023.01.30
* @since 1.0.0
*/
@Slf4j
public class HumanLoginFunction extends LoginFunction {
public HumanLoginFunction(BaseEtax etax) {
super(etax);
}
/**
* 自然人登录
*
* @param personId 自然人账号
* @param personPassword 自然人密码
* @return 登录结果
*/
public FunctionResult<Object> login(String personId, String personPassword) {
config().setLoginType(EtaxConfig.LOGIN_TYPE_HUMAN);
try {
rpa().visit(config().getMainUrl());
tryLogout();
rpa().visit(config().getDomain());
tryCloseNotices();
toLoginPage();
tryLogin(personId, personPassword);
ignoreSetPassword();
rpa().sleep(SLEEP_TIME);
if (!StringUtils.contains(rpa().url(), config().getHomeUri())) {
throw new EtaxException("出现未预料的登录流程!");
}
return new FunctionResult<>(true, "登录成功。", null);
} catch (Exception e) {
return handleException(e, "登录失败,");
}
}
/**
* 尝试进行自然人登录
*
* @param personId 自然人账号
* @param personPassword 自然人密码
*/
protected void tryLogin(String personId, String personPassword) {
rpa().click(rpa().findElementWithInnerText("自然人业务", By.cssSelector("div.justifyCenterEnd > span"), WAIT_TIME));
rpa().click(rpa().findElementWithInnerText("用户名登录", By.cssSelector("div.card > div"), WAIT_TIME));
// 8. 输入账号,密码
rpa().sendKey(By.cssSelector("input[placeholder=\"用户名\"]"), personId, true, WAIT_TIME);
rpa().sendKey(By.cssSelector("input[placeholder=\"个人用户密码(初始密码为证件号码后六位)\"]"), personPassword, true, WAIT_TIME);
// 9. 滑动验证
var dragHandler = rpa().findElement(By.cssSelector("div.drag > div.handler"), WAIT_TIME);
rpa().moveTo(dragHandler);
var drag = rpa().findElement(By.cssSelector("div.drag"));
var handlerRect = rpa().sumElementRectangle(drag);
rpa().dragTo(
new Point(
RandomKits.nextDouble(handlerRect.rightTop().x(), handlerRect.rightTop().x() + MOUSE_OFFSET),
RandomKits.nextDouble(handlerRect.rightTop().y() - MOUSE_OFFSET, handlerRect.rightBottom().y()) + MOUSE_OFFSET
)
);
// 10. 登录处理(监听登录 Ajax 结果)
rpa().newHar();
rpa().click(By.cssSelector("button.el-button.loginCls"), WAIT_TIME);
var response = rpa().jsonListen("auth/user/single/userNamePswLogin", SysApiResponse.class);
if (!response.success()) {
throw new EtaxException(response.msg());
}
}
}
package com.greatchn.etax.tianjin;
import com.greatchn.etax.BaseEtax;
import com.greatchn.etax.BaseFunction;
import com.greatchn.etax.FunctionResult;
import com.greatchn.etax.exceptions.EtaxException;
import com.greatchn.etax.sms.SmsBiz;
import com.greatchn.etax.tianjin.bean.SysApiResponse;
import com.greatchn.kits.RandomKits;
import com.greatchn.rpa.beans.Point;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
/**
* 电子税务局身份认证处理方法
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
@Slf4j
public class IdentityAuthenticationFunction extends BaseFunction {
public IdentityAuthenticationFunction(BaseEtax etax) {
super(etax);
}
/**
* 验证是否需要进行身份验证
*
* @return true 需要身份验证,false 不需要身份验证
*/
public boolean needIdentityAuthentication() {
try {
rpa().findElementWithInnerText("身份验证", By.id("dialog1title"), WAIT_TIME / 2);
return true;
} catch (NoSuchElementException e) {
return false;
}
}
/**
* 短信超限消息
*/
private static final String LIMIT_MESSAGE = "短信发送次数超限!";
public FunctionResult<Object> identityAuthentication() {
var currentUrl = rpa().url();
try {
var sessionUser = etax().function(SessionFunction.class).getSessionUser(false);
var targetUrl = rpa().findElement(By.id("ecrzkxDxId")).getAttribute("src");
// 因为跨域了,所以要将身份识别的 iframe 提升到顶级窗口
rpa().visit(targetUrl);
// 先进行滑动验证
var dragHandler = rpa().findElement(By.cssSelector("div.drag > div.handler"), WAIT_TIME);
rpa().moveTo(dragHandler);
var drag = rpa().findElement(By.cssSelector("div.drag"));
var handlerRect = rpa().sumElementRectangle(drag);
rpa().dragTo(
new Point(
RandomKits.nextDouble(handlerRect.rightTop().x(), handlerRect.rightTop().x() + MOUSE_OFFSET),
RandomKits.nextDouble(handlerRect.rightTop().y() - MOUSE_OFFSET, handlerRect.rightBottom().y()) + MOUSE_OFFSET
)
);
var code = SmsBiz.waitSmsCode(sessionUser.sjh(), SmsBiz.SmsTaskType.ETAX_TIANJIN, () -> {
rpa().newHar();
rpa().findElementWithInnerText("获取验证码", By.cssSelector("button.el-button"), WAIT_TIME).click();
var response = rpa().jsonListen("auth/oauth2/sendSmsByToke", SysApiResponse.class);
if (!response.success()) {
if (response.msg().contains(LIMIT_MESSAGE)) {
throw new EtaxException(response.msg());
}
}
});
rpa().sendKey(By.cssSelector("input.el-input__inner[placeholder=\"请输入短信验证码\"]"), code, false, WAIT_TIME);
rpa().newHar();
rpa().findElementWithInnerText("确定", By.cssSelector("button.el-button"), WAIT_TIME).click();
var response = rpa().jsonListen("auth/oauth2/verifySmsCode", SysApiResponse.class);
if (!response.success()) {
throw new EtaxException(response.msg());
}
return new FunctionResult<>(true, "身份认证成功。", null);
} catch (Exception e) {
return handleException(e, "身份认证失败,");
} finally {
rpa().visit(currentUrl);
}
}
}
package com.greatchn.etax.tianjin;
import com.greatchn.etax.BaseEtax;
import com.greatchn.etax.BaseFunction;
import com.greatchn.etax.EtaxConfig;
import com.greatchn.etax.FunctionResult;
import com.greatchn.etax.exceptions.EtaxException;
import com.greatchn.etax.tianjin.bean.SysApiResponse;
import com.greatchn.kits.RandomKits;
import com.greatchn.rpa.beans.Point;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
/**
* 登录方法
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
@Slf4j
public class LoginFunction extends BaseFunction {
public LoginFunction(BaseEtax etax) {
super(etax);
}
/**
* 天津市电子税务局登录
*
* @param taxpayerId 税号
* @param personId 办税人 ID
* @param personPassword 办税人密码
*/
protected FunctionResult<Object> login(String taxpayerId, String personId, String personPassword) {
config().setLoginType(EtaxConfig.LOGIN_TYPE_ENTERPRISE);
try {
rpa().visit(config().getMainUrl());
tryLogout();
rpa().visit(config().getDomain());
tryCloseNotices();
toLoginPage();
tryLogin(taxpayerId, personId, personPassword);
rpa().sleep(SLEEP_TIME);
choiceRole();
rpa().sleep(SLEEP_TIME);
acceptJoin();
rpa().sleep(SLEEP_TIME);
ignoreSetPassword();
rpa().sleep(SLEEP_TIME);
if (!StringUtils.contains(rpa().url(), config().getHomeUri())) {
throw new EtaxException("出现未预料的登录流程!");
}
// 尝试关闭通知
return new FunctionResult<>(true, "登录成功。", null);
} catch (Exception e) {
return handleException(e, "登录失败,");
}
}
/**
* 忽略修改密码处理
*/
protected void ignoreSetPassword() {
if (StringUtils.contains(rpa().url(), config().getHomeUri())) {
return;
}
try {
rpa().waitElement(By.cssSelector("input.el-input__inner[placeholder=\"请设置密码\"]"), WAIT_TIME);
rpa().refresh();
} catch (NoSuchElementException ignore) {
}
}
/**
* 角色加入确认
*/
protected void acceptJoin() {
if (StringUtils.contains(rpa().url(), config().getHomeUri())) {
return;
}
try {
var dialogs = rpa().findElements(By.cssSelector(".el-message-box__wrapper .el-message-box"), WAIT_TIME);
dialogs.forEach(dialog -> {
if (rpa().inElementText(rpa().findElement(dialog, By.cssSelector(".el-message-box__container")), "")) {
rpa().click(rpa().findElementWithInnerText("确定", dialog, By.cssSelector(".el-message-box__btns button.el-button")));
}
});
} catch (NoSuchElementException ignore) {
}
}
/**
* 选择角色
*/
protected void choiceRole() {
if (StringUtils.contains(rpa().url(), config().getHomeUri())) {
return;
}
// 12. 如果没有进入电子税务局首页,判断是否有角色选择处理
try {
rpa().newHar();
var choice = false;
for (var role : etax().getRoles()) {
try {
rpa().click(rpa().findElementWithInnerText(role, By.cssSelector(".el-radio .el-radio__label"), WAIT_TIME));
choice = true;
break;
} catch (NoSuchElementException ignore) {
}
}
if (!choice) {
throw new EtaxException("无法识别的角色!");
}
rpa().click(By.cssSelector("div.el-dialog[aria-label=\"身份类型选择\"] button.el-button.el-button--primary"), WAIT_TIME);
var response = rpa().jsonListen("auth/user/agreementListQuery", SysApiResponse.class);
if (!response.success()) {
throw new EtaxException(response.msg());
}
} catch (NoSuchElementException ignore) {
}
}
/**
* 尝试登录
*
* @param taxpayerId 税号
* @param personId 办税人账号
* @param personPassword 办税人密码
*/
protected void tryLogin(String taxpayerId, String personId, String personPassword) {
// 7. 切换到 企业业务 - 快捷登录 模式
rpa().click(rpa().findElementWithInnerText("企业业务", By.cssSelector("div.justifyCenterEnd > span"), WAIT_TIME));
rpa().click(rpa().findElementWithInnerText("快捷登录", By.cssSelector("div.card > div"), WAIT_TIME));
// 8. 输入账号,密码
rpa().sendKey(By.cssSelector("input[placeholder=\"统一社会信用代码/纳税人识别号\"]"), taxpayerId, true, WAIT_TIME);
rpa().sendKey(By.cssSelector("input[placeholder=\"居民身份证号码/手机号码/用户名\"]"), personId, true, WAIT_TIME);
rpa().sendKey(By.cssSelector("input[placeholder=\"个人用户密码(初始密码为证件号码后六位)\"]"), personPassword, true, WAIT_TIME);
// 9. 滑动验证
var dragHandler = rpa().findElement(By.cssSelector("div.drag > div.handler"), WAIT_TIME);
rpa().moveTo(dragHandler);
var drag = rpa().findElement(By.cssSelector("div.drag"));
var handlerRect = rpa().sumElementRectangle(drag);
rpa().dragTo(
new Point(
RandomKits.nextDouble(handlerRect.rightTop().x(), handlerRect.rightTop().x() + MOUSE_OFFSET),
RandomKits.nextDouble(handlerRect.rightTop().y() - MOUSE_OFFSET, handlerRect.rightBottom().y()) + MOUSE_OFFSET
)
);
// 10. 登录处理(监听登录 Ajax 结果)
rpa().newHar();
rpa().click(By.cssSelector("button.el-button.loginCls"), WAIT_TIME);
var response = rpa().jsonListen("auth/enterprise/quick/accountLogin", SysApiResponse.class);
if (!response.success()) {
throw new EtaxException(response.msg());
}
}
/**
* 访问登录页面
*/
protected void toLoginPage() {
try {
var loginButton = rpa().findElementWithInnerText("新版登录", By.cssSelector(".login-font"), WAIT_TIME);
rpa().click(loginButton);
} catch (NoSuchElementException e) {
// 6. 找不到登录按钮,通过电子税务局内部脚本获取登录地址
var loginUrl = rpa().executeJavaScript("return WSSW.kxLoingUrl", String.class);
if (StringUtils.isBlank(loginUrl)) {
throw new EtaxException("无法进入登录页面。");
}
rpa().visit(loginUrl);
}
}
/**
* 尝试关闭所有通知
*/
protected void tryCloseNotices() {
// 4. 尝试关闭所有通知框
try {
rpa().findElements(By.cssSelector("div.noticeContainer div.notice span.known"), WAIT_TIME).forEach(WebElement::click);
} catch (NoSuchElementException ignored) {
// 尝试性操作异常不需要处理
}
}
/**
* 尝试退出操作
*/
protected void tryLogout() {
// 2. 尝试进行退出操作
try {
rpa().waitElement(By.cssSelector("div.user span.userName span.userNameInfo"), WAIT_TIME);
// 通过脚本进行退出操作
rpa().newHar();
rpa().executeJavaScript("""
(function() {
try {
WSSW.index.outWSSW();
} catch(e) {
}
})();
""", Object.class);
rpa().listen("apps/view/login.html");
} catch (NoSuchElementException ignored) {
// 尝试性操作异常不需要处理
}
}
}
package com.greatchn.etax.tianjin;
import com.greatchn.etax.BaseEtax;
import com.greatchn.etax.BaseFunction;
import com.greatchn.etax.EtaxConfig;
import com.greatchn.etax.FunctionResult;
import com.greatchn.etax.exceptions.EtaxException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
import java.util.Objects;
/**
* 主菜单管理方法
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
@Slf4j
public class MenuFunction extends BaseFunction {
public MenuFunction(BaseEtax etax) {
super(etax);
}
/**
* 切换电子税务局主页菜单
*
* @param type 功能类型
* @param menu 功能名称
* @return 切换结果
*/
public FunctionResult<Object> choiceMainMenu(String type, String menu) {
// 1. 返回电子税务局首页
rpa().visit(config().getMainUrl());
try {
// 判断是否存在 divAlertBox 对话框,内容为
rpa().findElementWithInnerText("用户未登录!", By.cssSelector("#divAlertBox span"), WAIT_TIME / 2);
return new FunctionResult<>(false, "切换菜单失败,用户未登录!", null);
} catch (NoSuchElementException ignore) {
// 继续执行
}
// 切换菜单类型
try {
var typeTab = rpa().findElementWithInnerText(type, By.cssSelector("div.main-rightPane-topPane li[role=\"presentation\"] a[role=\"tab\"]"), WAIT_TIME);
rpa().click(typeTab);
if (config().getLoginType() == EtaxConfig.LOGIN_TYPE_ENTERPRISE) {
// 切换到菜单 iFrame
var typeId = typeTab.getAttribute("aria-controls");
rpa().switchToFrame(By.cssSelector("div#" + typeId + " iframe"), WAIT_TIME);
}
try {
rpa().click(rpa().findElementWithInnerText(menu, By.cssSelector("li.menuContainerLi span.menuContainerLi-span"), WAIT_TIME / 2));
} catch (Exception e) {
rpa().click(rpa().findElementWithInnerText(menu, By.cssSelector("li.menuContainerLi-file span.menuContainerLi-span"), WAIT_TIME / 2));
}
return new FunctionResult<>(true, "菜单切换成功。", null);
} catch (Exception e) {
return handleException(e, "菜单切换失败,");
} finally {
rpa().switchToParent();
}
}
/**
* 切换账户中心菜单
*
* @param first 一级菜单
* @param second 二级菜单
* @return 切换结果
*/
public FunctionResult<Object> choiceAccountCenterMenu(String first, String second) {
try {
var header = rpa().findElement(By.cssSelector(".content .sidebar-container .sidebar-head"));
var headerText = "账户中心";
if (!rpa().textEquals(header, headerText)) {
return new FunctionResult<>(false, "切换账户中心菜单失败,请先进入该功能!", null);
}
} catch (NoSuchElementException e) {
return new FunctionResult<>(false, "切换账户中心菜单失败," + e.getMessage(), null);
}
try {
// 切换一级菜单
rpa().findElementWithInnerText(first, By.cssSelector("li.el-submenu span"), WAIT_TIME).click();
// 切换二级菜单
rpa().findElement(By.cssSelector("li.el-menu-item[title=\"" + second + "\"]"), WAIT_TIME).click();
return new FunctionResult<>(true, "切换账户中心菜单成功。", null);
} catch (Exception e) {
return handleException(e, "切换账户中心菜单失败,");
}
}
/**
* 菜单标志:点 - 功能菜单
*/
private static final String MENU_DOT = "•";
/**
* 选择目录菜单
*
* @param menu 菜单内容
* @param needFolder true:目录菜单;false:文件菜单
* @return 菜单元素
*/
private WebElement choiceMenu(String menu, boolean needFolder) {
var menuElements = rpa().findElements(By.cssSelector(".leftPanel-li-div"), WAIT_TIME);
for (var menuElement : menuElements) {
var point = menuElement.findElement(By.cssSelector("span.leftPane-pointer"));
var isFile = rpa().textEquals(point, MENU_DOT);
if (needFolder ^ isFile) {
var spans = menuElement.findElements(By.tagName("span"));
for (var span : spans) {
if (rpa().textEquals(span, menu)) {
return span;
}
}
}
}
return null;
}
/**
* 进入功能菜单
*
* @param menus 菜单顺序
* @return 执行结果
*/
public FunctionResult<Object> choiceFunctionMenu(String... menus) {
if (Objects.isNull(menus) || menus.length == 0) {
return new FunctionResult<>(false, "功能菜单选择失败,未指定要进入的功能", null);
}
try {
for (var i = 0; i < menus.length; i++) {
WebElement menu;
if (i == menus.length - 1) {
menu = choiceMenu(menus[i], false);
} else {
menu = choiceMenu(menus[i], true);
}
if (Objects.isNull(menu)) {
throw new EtaxException(menus[i] + "不存在!");
} else {
menu.click();
}
}
// 身份识别判断
var functionObj = etax().function(IdentityAuthenticationFunction.class);
if (functionObj.needIdentityAuthentication()) {
var result = etax().function(IdentityAuthenticationFunction.class).identityAuthentication();
if (result.success()) {
return choiceFunctionMenu(menus);
} else {
throw new EtaxException(result.message());
}
}
// 是否可以使用判断
try {
var dialog = rpa().findElement(By.id("divAlertBox"), WAIT_TIME);
if (!Objects.isNull(dialog)) {
throw new EtaxException(StringUtils.trim(dialog.getText()));
}
} catch (Exception ignore) {
}
return new FunctionResult<>(true, "切换功能菜单成功。", null);
} catch (Exception e) {
return handleException(e, "切换功能菜单失败,");
}
}
}
package com.greatchn.etax.tianjin;
import com.greatchn.etax.BaseEtax;
import com.greatchn.etax.BaseFunction;
import com.greatchn.etax.FunctionResult;
import com.greatchn.etax.exceptions.EtaxException;
import com.greatchn.etax.tianjin.bean.SysApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.openqa.selenium.By;
import java.util.ArrayList;
import java.util.List;
/**
* 查询绑定企业 列表
*
* @author LWang 2023.01.30
* @since 1.0.0
*/
@Slf4j
public class QueryBindEnterpriseFunction extends BaseFunction {
public QueryBindEnterpriseFunction(BaseEtax etax) {
super(etax);
}
public FunctionResult<List<String>> queryBindEnterprise() {
// 切换到 用户中心 - 自然人账户中心
var menuFunctionObj = etax().function(MenuFunction.class);
var choiceMainMenuResult = menuFunctionObj.choiceMainMenu("用户中心", "自然人账户中心");
if (!choiceMainMenuResult.success()) {
return new FunctionResult<>(false, choiceMainMenuResult.message(), null);
}
var choiceAccountCenterMenuResult = menuFunctionObj.choiceAccountCenterMenu("企业授权管理", "已授权企业");
if (!choiceAccountCenterMenuResult.success()) {
return new FunctionResult<>(false, choiceAccountCenterMenuResult.message(), null);
}
try {
rpa().findElementWithInnerText("已授权企业", By.cssSelector("div.s_Breadcrumb"), WAIT_TIME);
var list = new ArrayList<String>();
// 每页长度改为 40
var page = rpa().findElement(By.cssSelector(".el-pagination .el-pagination__sizes .el-select"), WAIT_TIME);
rpa().click(page);
rpa().newHar();
rpa().click(rpa().findElementWithInnerText("40条/页", By.cssSelector(".el-select-dropdown__wrap .el-select-dropdown__item span"), WAIT_TIME));
var response = rpa().jsonListen("idm/internal/relation/selectRelationList", SysApiResponse.class);
if (!response.success()) {
throw new EtaxException(response.msg());
}
// 查询列表
while (true) {
var rows = rpa().findElements(By.cssSelector(".el-table__body-wrapper .el-table__row"), WAIT_TIME);
for (var row : rows) {
var cells = row.findElements(By.tagName("td"));
var line = new ArrayList<String>(cells.size());
for (var cell : cells) {
line.add(cell.getText());
}
list.add(StringUtils.join(line, "\t"));
}
// 读取下一页按钮
var nextPage = rpa().findElement(By.cssSelector(".btn-next"), WAIT_TIME);
if (!nextPage.isEnabled()) {
break;
}
rpa().newHar();
nextPage.click();
response = rpa().jsonListen("idm/internal/relation/selectRelationList", SysApiResponse.class);
if (!response.success()) {
throw new EtaxException(response.msg());
}
rpa().sleep(1000);
}
return new FunctionResult<>(true, "OK", list);
} catch (Exception e) {
return handleException(e, "查询绑定企业失败,");
}
}
}
package com.greatchn.etax.tianjin;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.greatchn.etax.BaseEtax;
import com.greatchn.etax.BaseFunction;
import com.greatchn.etax.FunctionResult;
import com.greatchn.etax.exceptions.EtaxException;
import com.greatchn.kits.DateTimeKits;
import com.greatchn.kits.HttpTool;
import com.greatchn.kits.JacksonKits;
import com.greatchn.kits.oss.OssKits;
import lombok.Data;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpStatus;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
import java.io.IOException;
import java.net.URISyntaxException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* 查询完税证明方法
*
* @author LWang 2023.02.02
* @since 1.0.0
*/
public class QueryDutyPaidProofFunction extends BaseFunction {
public QueryDutyPaidProofFunction(BaseEtax etax) {
super(etax);
}
/**
* 查询完税凭证
*
* @param taxpayerId 纳税人登记序号
* @param startDate 开始时间
* @param endDate 结束时间
* @return 保存到 OSS 中的文件 ID
*/
public FunctionResult<String> queryDutyPaidProof(String taxpayerId, String startDate, String endDate) {
// 进入功能处理流程
try {
var menuFunction = etax().function(MenuFunction.class);
// 切换主菜单
var choiceMainMenuResult = menuFunction.choiceMainMenu("我要办税", "证明开具");
if (!choiceMainMenuResult.success()) {
return new FunctionResult<>(false, choiceMainMenuResult.message(), null);
}
// 切换功能菜单
var choiceFunctionMenuResult = menuFunction.choiceFunctionMenu("开具税收完税(费)证明(文书式)");
if (!choiceFunctionMenuResult.success()) {
return new FunctionResult<>(false, choiceFunctionMenuResult.message(), null);
}
// 切换进入实际工作 iFrame
rpa().switchToFrame(By.id("iframeContainerPaneId"), WAIT_TIME);
// 选择企业,并下一步进行查询
rpa().click(By.cssSelector("input[type=\"radio\"][value=\"%s\"]".formatted(taxpayerId)), WAIT_TIME);
rpa().click(By.cssSelector("input.next[type=\"button\"][value=\"下一步\"]"), WAIT_TIME);
// 设置查询条件
rpa().click(By.cssSelector("input#dygs[value=\"1\"]"), WAIT_TIME);
rpa().click(By.id("nspzhkzl"), WAIT_TIME);
rpa().findElementWithInnerText("税收完税证明(文书式)", By.cssSelector("select#nspzhkzl option"), WAIT_TIME).click();
rpa().executeJavaScript("document.querySelector('#skssqq').value=arguments[0]", Object.class, startDate);
rpa().executeJavaScript("document.querySelector('#skssqz').value=arguments[0]", Object.class, endDate);
rpa().executeJavaScript("""
(function() {
try {
window.alert = function() {}
window.confirm = function() {return true;}
} catch (e) {
}
})();
""", Object.class);
rpa().newHar();
rpa().click(By.cssSelector("input.last[value=\"查询\"]"), WAIT_TIME);
var response = rpa().jsonListen("WsbsWebBn/wsbsWszmAction_queryJks.do");
var code = response.get("returnCode").asText(CODE_ERROR);
var rows = response.get("rows");
if (!Objects.equals(code, CODE_SUCCESS)) {
throw new EtaxException("未查询到符合要求的数据,请检查对应的税款是否入库");
}
if (rows instanceof ArrayNode arrayNode && arrayNode.size() <= 0) {
throw new EtaxException("未查询到符合要求的数据,请检查对应的税款是否入库");
}
// 替换页面中的 savDataCallback 方法,不执行页面跳转操作
rpa().executeJavaScript("window.savDataCallback = function(applicationId){}", Object.class);
var sn = StringUtils.EMPTY;
var submit = rpa().findElement(By.cssSelector("input.next[value=\"下一步\"]"));
while (StringUtils.isBlank(sn)) {
rpa().newHar();
submit.click();
response = rpa().jsonListen("WsbsWebBn/wsbsWszmAction_saveWszmxx.do");
if (Objects.isNull(response)) {
throw new EtaxException("开具完税证明过程出现异常,请重新登录!");
}
var result = response.get("result");
if (Objects.isNull(result)) {
throw new EtaxException("开具完税证明过程出现异常,请重新登录!");
}
var saveResult = JacksonKits.toBean(result.asText("{}"), SaveResult.class);
assert saveResult != null;
if (Objects.equals("modify", saveResult.flag)) {
throw new EtaxException("请勿篡改完税信息!");
}
if (Objects.equals("session", saveResult.flag)) {
throw new EtaxException("当前登录用户异常,请重新登录!");
}
if (Objects.equals("N", saveResult.flag)) {
sn = saveResult.lsh;
break;
}
rpa().sleep(SLEEP_TIME);
}
// 使用流水号查询完税证明
return downloadDutyPaidProof(sn);
} catch (Exception e) {
return handleException(e, "查询完税证明失败,");
} finally {
rpa().switchToParent();
}
}
/**
* 下载完税凭证
*
* @param sn 流水号
* @return 保存在 SSO 中的文件编号
*/
private FunctionResult<String> downloadDutyPaidProof(String sn) throws NoSuchAlgorithmException, IOException, InterruptedException, KeyManagementException, URISyntaxException {
rpa().switchToParent();
var menuFunction = etax().function(MenuFunction.class);
// 切换主菜单
var choiceMainMenuResult = menuFunction.choiceMainMenu("我要办税", "证明开具");
if (!choiceMainMenuResult.success()) {
return new FunctionResult<>(false, choiceMainMenuResult.message(), null);
}
// 切换功能菜单
var choiceFunctionMenuResult = menuFunction.choiceFunctionMenu("查询已开具税收完税(费)证明(文书式)");
if (!choiceFunctionMenuResult.success()) {
return new FunctionResult<>(false, choiceFunctionMenuResult.message(), null);
}
rpa().switchToFrame(By.id("iframeContainerPaneId"), WAIT_TIME);
// 设置查询参数
rpa().sendKey(By.id("sqsjq"), "1970-01-01", true, WAIT_TIME);
rpa().sendKey(By.id("sqsjz"), DateTimeKits.currentDay(), true, WAIT_TIME);
rpa().sendKey(By.id("lsh"), sn, true, WAIT_TIME);
rpa().newHar();
rpa().findElement(By.cssSelector("input[type=\"button\"][value=\"查询\"]"), WAIT_TIME).click();
rpa().listen("wsbsWszmAction_queryYkjWszmxx.do");
// 查询表格
var rows = rpa().findElements(By.cssSelector("div.base table.table tbody tr"), WAIT_TIME);
for (var row : rows) {
WebElement downloadButton;
try {
downloadButton = row.findElement(By.cssSelector("input.applybtn[value=\"下载证明\"]"));
} catch (NoSuchElementException e) {
continue;
}
if (rpa().inElementText(row, sn)) {
var downloadInfo = downloadButton.getAttribute("onclick");
var matcher = Pattern.compile("download\\('(.+?)','(.+?)'\\)").matcher(downloadInfo);
if (matcher.find()) {
return new FunctionResult<>(true, "下载完成完税证明成功。", downloadPdf(matcher.group(1), matcher.group(2)));
} else {
throw new EtaxException("根据流水号下载晚熟凭证失败,流水号 " + sn + "对应的完税证明不存在!");
}
}
}
return null;
}
/**
* 下载完税凭证
*
* @param applicationId 应用 ID
* @param typeId 种类 ID
* @return OSS 资源 ID
*/
private String downloadPdf(String applicationId, String typeId) throws NoSuchAlgorithmException, KeyManagementException, IOException, InterruptedException {
var url = "%sWsbsWebBn/wsbsWszmAction_showPdf.do?applicationId=%s&zlId=%s".formatted(config().getDomain(), applicationId, typeId);
var httpClient = HttpTool.build();
var downloadInfo = httpClient.download(url, "pdf");
if (!downloadInfo.getKey().equals(HttpStatus.SC_OK)) {
throw new EtaxException("无法下载完税证明,对应的文件不存在!");
}
try {
return OssKits.upload(downloadInfo.getValue().toFile());
} finally {
FileUtils.delete(downloadInfo.getValue().toFile());
}
}
private final static String CODE_ERROR = "99";
private final static String CODE_SUCCESS = "00";
@Data
public static class SaveResult {
private String flag;
private String lsh;
}
}
package com.greatchn.etax.tianjin;
import com.greatchn.etax.BaseEtax;
import com.greatchn.etax.BaseFunction;
import com.greatchn.etax.tianjin.bean.SessionUser;
import com.greatchn.kits.JacksonKits;
import org.openqa.selenium.By;
/**
* 会话内容管理方法
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
public class SessionFunction extends BaseFunction {
public SessionFunction(BaseEtax etax) {
super(etax);
}
/**
* 获取当前登录用户信息
*
* @param refresh 是否刷新页面
* @return 登录用户信息
*/
public SessionUser getSessionUser(boolean refresh) {
try {
if (refresh) {
rpa().refresh();
rpa().sleep(SLEEP_TIME);
}
rpa().waitElement(By.cssSelector("div.user span.userName span.userNameInfo"), WAIT_TIME);
var val = rpa().executeJavaScript("""
return (function() {
try {
return JSON.stringify(WSSW.sessionUser);
} catch(e) {
return "{}";
}
})();
""", String.class);
return JacksonKits.toBean(val, SessionUser.class);
} catch (Exception ignore) {
}
return null;
}
}
package com.greatchn.etax.tianjin;
import com.greatchn.etax.BaseEtax;
import com.greatchn.etax.BaseFunction;
import com.greatchn.etax.FunctionResult;
import com.greatchn.etax.exceptions.EtaxException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
import java.util.Objects;
/**
* 企业切换管理方法
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
@Slf4j
public class SwitchTaxpayerFunction extends BaseFunction {
public SwitchTaxpayerFunction(BaseEtax etax) {
super(etax);
}
/**
* 切换企业
*
* @param taxFileNo 税号
* @return 切换结果
*/
public FunctionResult<Object> switchTaxpayer(String taxFileNo) {
// 1. 进入“我的信息” - “企业账户中心” 功能
{
var functionResult = etax().function(MenuFunction.class).choiceMainMenu("我的信息", "企业账户中心(代理账号中心)");
if (!functionResult.success()) {
return new FunctionResult<>(false, "切换企业失败," + functionResult.message(), null);
}
}
// 2. 进入“身份切换” - “企业办税” 功能
{
var functionResult = etax().function(MenuFunction.class).choiceAccountCenterMenu("身份切换", "企业办税");
if (!functionResult.success()) {
return new FunctionResult<>(false, "切换企业失败," + functionResult.message(), null);
}
}
try {
rpa().findElementWithInnerText("企业办税", By.cssSelector("div.s_Breadcrumb"), WAIT_TIME);
// 3. 切换到“企业企业切换”标签
rpa().click(By.id("tab-2"), WAIT_TIME);
// 4. 输入要切换到的税号
rpa().sendKey(By.cssSelector("input.el-input__inner[placeholder=\"请输入统一社会信用代码\"]"), taxFileNo, true, WAIT_TIME);
// 5. 查询,并等待结果返回
rpa().newHar();
rpa().findElementWithInnerText("查询", By.cssSelector("div.other-enterprise div.search-form button.el-button span"), WAIT_TIME).click();
rpa().listen("idm/internal/relation/selectRelationList");
// 6. 在结果列表中查找税号是否存在
var rows = rpa().findElements(By.cssSelector("div.other-enterprise table.el-table__body tr.el-table__row"), WAIT_TIME);
WebElement choiceButton = null;
for (var row : rows) {
var cells = row.findElements(By.cssSelector("td .cell"));
for (var cell : cells) {
if (rpa().textEquals(cell, taxFileNo)) {
choiceButton = row.findElement(By.cssSelector(".el-button.el-button--text.el-button--small"));
}
}
if (!Objects.isNull(choiceButton)) {
break;
}
}
// 7. 执行切换功能
if (Objects.isNull(choiceButton)) {
throw new EtaxException("当前用户未关联" + taxFileNo);
}
rpa().newHar();
choiceButton.click();
rpa().listen("idm/internal/relation/selectRelationList");
rpa().sleep(SLEEP_TIME);
// 判断 URL 是否是税务局首页,如果不是,查找角色选择对话框
if (!StringUtils.contains(rpa().url(), config().getHomeUri())) {
var dialog = rpa().findElement(By.cssSelector(".el-dialog.el-dialog--center"), WAIT_TIME);
var header = dialog.findElement(By.cssSelector(".el-dialog__title"));
var headerText = "身份类型选择";
if (rpa().textEquals(header, headerText)) {
var choice = false;
for (var role : etax().getRoles()) {
try {
rpa().click(rpa().findElementWithInnerText(role, By.cssSelector(".el-radio .el-radio__label"), WAIT_TIME));
choice = true;
break;
} catch (NoSuchElementException ignore) {
}
}
if (!choice) {
throw new EtaxException("无法识别的角色!");
}
dialog.findElement(By.cssSelector("button.el-button.el-button--primary")).click();
} else {
throw new EtaxException("出现未知的对话框信息," + header.getText());
}
}
// 8. 等待返回电子税务局首页
rpa().waitElement(By.cssSelector("div.user span.userName span.userNameInfo"), WAIT_TIME);
// 8.1 补充……因为电子税务局的 bug,需要清理电子税务局内部网上办税子系统的 Cookies
try {
rpa().removeCookies("WSBSSESSION");
} catch (Exception ignore) {
}
// 9. 返回结果
return new FunctionResult<>(true, "切换企业成功。", null);
} catch (Exception e) {
return handleException(e, "切换企业失败,发生未知异常!");
}
}
}
package com.greatchn.etax.tianjin;
import com.greatchn.etax.BaseEtax;
import com.greatchn.etax.FunctionResult;
import com.greatchn.rpa.config.RpaConfig;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Objects;
/**
* 天津市电子税务局交互处理对象
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
@Slf4j
public class Tianjin extends BaseEtax {
private static final String TIANJIN_CONFIG = "etax/tianjin.yaml";
/**
* 天津市电子税务局对象构造方法
*
* @param rpaConfig rpa 配置
*/
public Tianjin(RpaConfig rpaConfig) {
this(rpaConfig, TIANJIN_CONFIG);
}
/**
* 天津市电子税务局对象构造方法
*
* @param rpaConfig rpa 配置
* @param etaxConfigPath 电子税务局配置地址
*/
public Tianjin(RpaConfig rpaConfig, String etaxConfigPath) {
super(rpaConfig, etaxConfigPath);
if (!rpa().isCreate()) {
rpa().create();
}
}
/**
* 电子税务局登录
*
* @param taxFileNo 税号
* @param personId 办税人 ID
* @param personPassword 办税人密码
* @return 登录操作结果
*/
public FunctionResult<Object> login(String taxFileNo, String personId, String personPassword) {
// 1. 登录状态判断,
var sessionUser = this.function(SessionFunction.class).getSessionUser(true);
if (!Objects.isNull(sessionUser)) {
// 判断当前登录用户是否与要登录的用户一致
if (Objects.equals(sessionUser.userName(), personId)) {
if (Objects.equals(taxFileNo, sessionUser.taxFileNo())) {
return new FunctionResult<>(true, "登录成功。", null);
}
// 尝试切换企业
if (this.function(SwitchTaxpayerFunction.class).switchTaxpayer(taxFileNo).success()) {
return new FunctionResult<>(true, "登录成功。", null);
}
}
}
return this.function(LoginFunction.class).login(taxFileNo, personId, personPassword);
}
/**
* 查询自然人绑定企业信息
*
* @param personId 自然人账号
* @param personPassword 自然人密码
* @return 查询结果
*/
public FunctionResult<List<String>> queryEnterpriseInHuman(String personId, String personPassword) {
var loginResult = this.function(HumanLoginFunction.class).login(personId, personPassword);
if (!loginResult.success()) {
return new FunctionResult<>(false, loginResult.message(), null);
}
return this.function(QueryBindEnterpriseFunction.class).queryBindEnterprise();
}
/**
* 查询纳税人完税凭证
*
* @param taxFileNo 纳税人识别号
* @param personId 办税人账号
* @param personPassword 办税人密码
* @param taxpayerId 登记序号
* @param startDate 查询开始时间
* @param endDate 查询终止时间
* @return 提交到 OSS 中的文件编号
*/
public FunctionResult<String> queryDutyPaidProof(String taxFileNo, String personId, String personPassword, String taxpayerId, String startDate, String endDate) {
// 当前发票需要办税类角色
this.setRole(ROLE_TYPE_TAX);
// 1. 登录
var result = login(taxFileNo, personId, personPassword);
if (!result.success()) {
// 登录失败直接返回
return new FunctionResult<>(false, result.message(), null);
}
// 2. 查询完税证明,并返回结果
return this.function(QueryDutyPaidProofFunction.class).queryDutyPaidProof(taxpayerId, startDate, endDate);
}
}
package com.greatchn.etax.tianjin.bean;
/**
* 天津市电子税务局会话用户信息
*
* @param token 令牌
* @param ip IP
* @param loginType 登录类型
* @param newloginbs 首次登录表示
* @param gdbz 国地税标志
* @param ryyzlx 人员认证类型
* @param ryId 人员 ID
* @param frName 法人名称
* @param ryName 人员名称
* @param ryType 人员类型
* @param sjh 手机号
* @param yys 暂时未知
* @param ryTypeMc 人员类型名称
* @param ryZjLxDm 人员证件类型代码
* @param ryZjLxMc 人员证件类型名称
* @param ryZjhm 人员证件号码
* @param id 某种 ID,用途未知
* @param nsrmc 纳税人名称
* @param ykNsrmc 某种纳税人名称,用途未知
* @param ssdabh 税收档案编号
* @param djxh 登记序号
* @param nsrsbhG 国税:纳税人识别号
* @param djxhG 国税:登记序号
* @param shxydmG 国税:社会信用代码
* @param swjgDmG 国税:税务机构代码
* @param swjgMcG 国税:税务机构名称
* @param swksDmG 国税:税务科室代码
* @param swksMcG 国税:税务科室明晨
* @param swryDMG 国税:税务人员代码
* @param swryMcG 国税:税务人员名称
* @param nsrsbhD 地税:纳税人识别号
* @param djxhD 地税:登记序号
* @param shxydmD 地税:社会信用代码
* @param swjgDmD 地税:税务机构代码
* @param swjgMcD 地税:税务机构名称
* @param swksDmD 地税:税务科室代码
* @param swksMcD 地税:税务科室名称
* @param swryDMD 地税:税务人员代码
* @param swryMcD 地税:税务人员名称
* @param xydj 信用登记
* @param confirmStatus 信息确认状态
* @param clientId 客户端 ID
* @param userType 用户类型
* @param enterpriseType 企业类型
* @param initialEnterpriseType 企业初始状态
* @param userId 用户 ID
* @param enterpriseId 企业 ID
* @param agencyEnterpriseId 代理机构 ID
* @param userName 当前登录用户个性化账号
* @param trustedLevel 信用登记名称
* @param loginLevel 登录登记
* @param lastTrustedDate 信用登记更新时间
* @param lastFaceVerifyDate 人脸识别时间
* @param registerTime 登录用户注册时间
* @param taxFileNo 税号
* @param taxpayerStatusCode 纳税人状态代码
* @param areaPrefix 区域编号
* @param areaPreName 区域名称
* @param areaName 地区名称
* @param crossInspectionNumber 跨地区客户编号
* @param projectName 项目名称
* @param crossRegionalPropertyTaxSubjectRegistrationMark 跨区注册标志
* @param gender 性别
* @param nationality 国籍
* @param startDate 启用时间
* @param endDate 停用时间
* @param birthdate 出生日期
* @param address 联系地址
* @param email 联系电子邮箱
* @param enterpriseStatus 企业状态
* @param loginBz 登录标志
* @author LWang 2023.01.30
* @since 1.0.0
*/
public record SessionUser(
String token,
String ip,
String loginType,
String newloginbs,
String gdbz,
String ryyzlx,
String ryId,
String frName,
String ryName,
String ryType,
String sjh,
String yys,
String ryTypeMc,
String ryZjLxDm,
String ryZjLxMc,
String ryZjhm,
String id,
String nsrmc,
String ykNsrmc,
String ssdabh,
String djxh,
String nsrsbhG,
String djxhG,
String shxydmG,
String swjgDmG,
String swjgMcG,
String swksDmG,
String swksMcG,
String swryDMG,
String swryMcG,
String nsrsbhD,
String djxhD,
String shxydmD,
String swjgDmD,
String swjgMcD,
String swksDmD,
String swksMcD,
String swryDMD,
String swryMcD,
String xydj,
String confirmStatus,
String clientId,
String userType,
String enterpriseType,
String initialEnterpriseType,
String userId,
String enterpriseId,
String agencyEnterpriseId,
String userName,
String trustedLevel,
String loginLevel,
String lastTrustedDate,
String lastFaceVerifyDate,
String registerTime,
String taxFileNo,
String taxpayerStatusCode,
String areaPrefix,
String areaPreName,
String areaName,
String crossInspectionNumber,
String projectName,
String crossRegionalPropertyTaxSubjectRegistrationMark,
String gender,
String nationality,
String startDate,
String endDate,
String birthdate,
String address,
String email,
String enterpriseStatus,
String loginBz
) {
}
package com.greatchn.etax.tianjin.bean;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
/**
* 天津市电子税务局统一身份认证 Ajax 响应对象
*
* @author LWang 2023.02.01
* @since 1.0.0
*/
@Data
public class SysApiResponse {
/**
* 响应码
*/
private int code;
/**
* 响应消息
*/
private String msg;
/**
* 压缩码
*/
private String zipCode;
/**
* 加密码
*/
private String encryptCode;
/**
* 响应数据
*/
private String datagram;
/**
* 签名类型
*/
private String signtype;
/**
* 签名
*/
private String signature;
/**
* 响应时间戳
*/
private String timestamp;
protected static final int SUCCESS_CODE = 1000;
protected static final String DEFAULT_MESSAGE = "网络异常!";
public boolean success() {
return SUCCESS_CODE == this.getCode();
}
public String msg() {
return StringUtils.isBlank(this.getMsg()) ? DEFAULT_MESSAGE : this.getMsg();
}
}
package com.greatchn.kits;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
/**
* 日期时间工具
*
* @author LWang 2023.02.01
* @since 1.0.0
*/
public final class DateTimeKits {
private DateTimeKits() {
}
/**
* 计算今日剩余时间
*
* @param unit 时间单位,只支持 SECONDS,MINUTES,HOURS
* @return 剩余时间
*/
public static long timeRemaining(ChronoUnit unit) {
var now = LocalDateTime.now();
var tomorrow = now.plusDays(1).withHour(0).withMinute(0).withSecond(0);
var duration = Duration.between(now, tomorrow);
switch (unit) {
case SECONDS -> {
return duration.toSeconds();
}
case MINUTES -> {
return duration.toMinutes();
}
case HOURS -> {
return duration.toHours();
}
default -> throw new IllegalArgumentException("时间单位不符合预期!");
}
}
/**
* 获取当前日期(yyyy-MM-dd)格式
*
* @return 当前日期
*/
public static String currentDay() {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.CHINA);
return formatter.format(LocalDate.now());
}
public static void main(String[] args) {
System.out.println(currentDay());
}
}
package com.greatchn.kits;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import java.lang.management.ManagementFactory;
import java.net.Inet4Address;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/**
* 系统环境信息处理工具
*
* @author LWang 2023.01.19
* @since 1.0.0
*/
@Slf4j
public final class EnvironmentToolkit {
private EnvironmentToolkit() {
}
/**
* 本机 IP 信息,防止重复获取
*/
private static volatile String LOCAL_IP;
private static volatile String MAC;
private static volatile List<Integer> LOCAL_IP_VALUES;
private static final String UNKNOWN = "unknown";
private static final String COMMA = ",";
private static synchronized void getIpInfos() {
if (StringUtils.isBlank(LOCAL_IP) || Objects.isNull(LOCAL_IP_VALUES) || LOCAL_IP_VALUES.isEmpty()) {
try {
final var ipSet = new HashSet<String>();
final var macSet = new HashSet<>();
LOCAL_IP_VALUES = new ArrayList<>();
NetworkInterface
.getNetworkInterfaces()
.asIterator()
.forEachRemaining(networkInterface -> {
try {
if (networkInterface.isUp() && !networkInterface.isLoopback() && !networkInterface.isVirtual()) {
networkInterface
.getInetAddresses()
.asIterator()
.forEachRemaining(inetAddress -> {
if (inetAddress instanceof Inet4Address) {
ipSet.add(inetAddress.getHostAddress());
byte[] ipAddress = inetAddress.getAddress();
LOCAL_IP_VALUES.add((ipAddress[0] << 24) & (ipAddress[1] << 16) & (ipAddress[2] << 8) & ipAddress[3]);
}
});
var hardwareAddress = networkInterface.getHardwareAddress();
if (ArrayUtils.isNotEmpty(hardwareAddress)) {
macSet.add(HashKits.toHex(hardwareAddress));
}
}
} catch (SocketException ex) {
log.error("获取本机 IP 地址发生异常:", ex);
}
});
LOCAL_IP = StringUtils.join(ipSet, COMMA);
MAC = StringUtils.join(macSet, COMMA);
} catch (SocketException e) {
log.error("获取本机 IP 地址发生异常:", e);
}
}
}
/**
* 获取本机 IP
* 返回结果忽略本地回环地址(127.0.0.1),localhost 和 IPv6 地址,如果是明确的虚拟网卡也忽略
*
* @return 本机 IP(多个 IP 使用 "," 连接)
*/
public static String getLocalIp() {
if (StringUtils.isBlank(LOCAL_IP)) {
getIpInfos();
}
return LOCAL_IP;
}
/**
* 获取本机 MAC 地址
*
* @return 本机 MAC(多个 MAC 使用 "," 连接)
*/
public static String getMac() {
if (StringUtils.isBlank(MAC)) {
getIpInfos();
}
return MAC;
}
/**
* 获取本机 IP 值
* 返回结果忽略本地回环地址(127.0.0.1),localhost 和 IPv6 地址,如果是明确的虚拟网卡也忽略
*
* @return 整型列表,IP 地址的 int 表示
*/
public static List<Integer> getLocalIpValue() {
if (Objects.isNull(LOCAL_IP_VALUES) || LOCAL_IP_VALUES.isEmpty()) {
getIpInfos();
}
return LOCAL_IP_VALUES;
}
/**
* 获取当前进程 ID
*
* @return 当前进程 ID
*/
public static long getProcessId() {
return ManagementFactory.getRuntimeMXBean().getPid();
}
}
package com.greatchn.kits;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
/**
* 哈希工具
*
* @author LWang 2023.01.19
* @since 1.0.0
*/
public final class HashKits {
private HashKits() {
}
private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray();
/**
* 将字节数组转换为 Hex
*
* @param bytes 要转换的字节数据
* @return 转换后的 HEX 字符串
*/
public static String toHex(byte[] bytes) {
return toHex(bytes, false);
}
/**
* 将字节数组转换为 Hex
*
* @param bytes 要转换的字节数据
* @param upperCase 结果转换为大写字母
* @return 转换后的 HEX 字符串
*/
public static String toHex(byte[] bytes, boolean upperCase) {
if (ArrayUtils.isEmpty(bytes)) {
return StringUtils.EMPTY;
}
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(HEX_DIGITS[0x0F & (b >> 4)]);
result.append(HEX_DIGITS[0x0F & b]);
}
return upperCase ? result.toString().toUpperCase() : result.toString().toLowerCase();
}
/**
* 将 Hex 字符串反编码为 byte 数组
*
* @param hexStr 要反编码的字符串
* @return 反编码结果
*/
public static byte[] decodeHex(String hexStr) {
if (StringUtils.isBlank(hexStr)) {
throw new IllegalArgumentException("反编码 HEX 信息错误,目标字符串为空");
}
return decodeHex(hexStr.toCharArray());
}
/**
* 将 Hex 字符数组反编码为 byte 数组
*
* @param chars 要反编码的字符数组
* @return 反编码结果
*/
public static byte[] decodeHex(char[] chars) {
if (ArrayUtils.isEmpty(chars)) {
throw new IllegalArgumentException("反编码 HEX 信息错误,目标字符数组为空!");
}
if (chars.length % 2 != 0) {
throw new IllegalArgumentException("反编码 HEX 信息错误,目标字符数组长度不是偶数!");
}
ByteBuffer byteBuffer = ByteBuffer.allocate(chars.length / 2);
for (int i = 0; i < chars.length; i += 2) {
byteBuffer.put((byte) ((toDigit(chars[i]) << 4) | toDigit(chars[i + 1])));
}
return byteBuffer.array();
}
private static int toDigit(char c) {
int digit = Character.digit(c, 16);
if (digit < 0) {
throw new IllegalArgumentException(String.format("无法将字符 %c 转换为十六进制数字!", c));
}
return digit;
}
/**
* 将字节数组转换为 MD5 摘要
*
* @param bytes 要转换的字节数组
* @return 转换结果,HEX 字符串
*/
public static String md5(byte[] bytes) {
return md5(bytes, false);
}
/**
* 将字节数组转换为 MD5 摘要
*
* @param bytes 要转换的字节数组
* @param upperCase 结果是否大写
* @return 转换结果,HEX 字符串
*/
public static String md5(byte[] bytes, boolean upperCase) {
return hash("MD5", bytes, upperCase);
}
/**
* 将字节数组转换为 SHA1 摘要
*
* @param bytes 要转换的字节数组
* @return 转换结果,HEX 字符串
*/
public static String sha1(byte[] bytes) {
return sha1(bytes, false);
}
/**
* 将字节数组转换为 SHA1 摘要
*
* @param bytes 要转换的字节数组
* @param upperCase 结果是否大写
* @return 转换结果,HEX 字符串
*/
public static String sha1(byte[] bytes, boolean upperCase) {
return hash("SHA-1", bytes, upperCase);
}
/**
* 将字节数组转换为 SHA256 摘要
*
* @param bytes 要转换的字节数组
* @return 转换结果,HEX 字符串
*/
public static String sha256(byte[] bytes) {
return sha256(bytes, false);
}
/**
* 将字节数组转换为 SHA256 摘要
*
* @param bytes 要转换的字节数组
* @param upperCase 结果是否大写
* @return 转换结果,HEX 字符串
*/
public static String sha256(byte[] bytes, boolean upperCase) {
return hash("SHA-256", bytes, upperCase);
}
/**
* 将字节数组转换为 SHA512 摘要
*
* @param bytes 要转换的字节数组
* @return 转换结果,HEX 字符串
*/
public static String sha512(byte[] bytes) {
return sha512(bytes, false);
}
/**
* 将字节数组转换为 SHA512 摘要
*
* @param bytes 要转换的字节数组
* @param upperCase 结果是否大写
* @return 转换结果,HEX 字符串
*/
public static String sha512(byte[] bytes, boolean upperCase) {
return hash("SHA-512", bytes, upperCase);
}
/**
* 生成基于 SHA 256 摘要算法的签名数据,结果使用 HEX 方式编码
*
* @param parameters 要进行签名的数据
* @param secretKey 安全密钥键,如果为空,表示不设置安全密钥
* @param secretValue 安全密钥值,如果 secretKey 不为空,则 secretValue 必须存在
* @return 签名结果
*/
public static String signatureWithSha256(final Map<String, String> parameters, String secretKey, String secretValue) {
if (Objects.isNull(parameters)) {
throw new IllegalArgumentException("签名数据不能为空!");
}
if (StringUtils.isNotBlank(secretKey) && StringUtils.isBlank(secretValue)) {
throw new IllegalArgumentException("安全密钥值不能为空!");
}
var sortMap = new TreeMap<>(parameters);
if (StringUtils.isNotBlank(secretKey)) {
sortMap.put(secretKey, secretValue);
}
var data = StringUtils.join(sortMap.values()).getBytes(StandardCharsets.UTF_8);
return sha256(data);
}
/**
* 对基于 SHA 256 摘要算法生成的签名进行验签
*
* @param parameters 待验签的数据
* @param signature 签名数据
* @param secretKey 安全密钥键,如果为空,表示不设置安全密钥
* @param secretValue 安全密钥值,如果 secretKey 不为空,则 secretValue 必须存在
* @return 验签结果
*/
public static boolean verifyWithSha256(final Map<String, String> parameters, String signature, String secretKey, String secretValue) {
return Objects.equals(
signatureWithSha256(parameters, secretKey, secretValue),
signature
);
}
/**
* 进行摘要计算
*
* @param algorithm 算法
* @param bytes 要进行摘要计算的字节数组
* @param upperCase 结果是否大写
* @return 转换结果,HEX 字符串
*/
private static String hash(String algorithm, byte[] bytes, boolean upperCase) {
try {
MessageDigest md = MessageDigest.getInstance(algorithm);
return toHex(md.digest(bytes), upperCase);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
package com.greatchn.kits;
import com.greatchn.kits.oss.OssKits;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.CookieManager;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.*;
/**
* 基于 jdk HttpClient 的网络访问工具
*
* @author LWang 2023.02.01
* @since 1.0.0
*/
@Slf4j
public final class HttpTool {
private final HttpClient httpClient;
private final CookieManager cookieManager;
public HttpTool() throws NoSuchAlgorithmException, KeyManagementException {
// 取消主机签名验证
System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true");
// 信任证书
var trustAllCertificates = new TrustManager[]{new InnerX509TrustManager()};
var sslParameters = new SSLParameters();
sslParameters.setEndpointIdentificationAlgorithm(StringUtils.EMPTY);
var sslContext = SSLContext.getInstance("SSL");
sslContext.init(null, trustAllCertificates, new SecureRandom());
cookieManager = new CookieManager();
httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(WAIT_TIMEOUT))
.cookieHandler(cookieManager)
.followRedirects(HttpClient.Redirect.NEVER)
.sslContext(sslContext)
.sslParameters(sslParameters)
.build();
}
/**
* 创建请求 Builder 对象
*
* @param uri 请求 URI
* @param headers 请求头
* @return Request Builder 对象
*/
private HttpRequest.Builder createRequestBuilder(URI uri, Map<String, String> headers) {
if (Objects.isNull(uri)) {
throw new IllegalArgumentException("缺少必要请求参数:uri");
}
var builder = HttpRequest.newBuilder().uri(uri).expectContinue(false);
if (!Objects.isNull(headers)) {
headers.forEach(builder::setHeader);
}
return builder;
}
/**
* 拼装查询字符串
*
* @param parameters 请求参数
* @return 拼装后的查询字符串
*/
private String assemblyParameters(List<Pair<String, String>> parameters) {
if (Objects.isNull(parameters) || parameters.isEmpty()) {
return StringUtils.EMPTY;
}
final var arrays = new ArrayList<String>(parameters.size());
parameters.forEach(pair -> arrays.add(String.format("%s%c%s", pair.getKey(), EQUAL_SIGN, URLEncoder.encode(pair.getValue(), StandardCharsets.UTF_8))));
return StringUtils.join(arrays, AND_MARK);
}
/**
* 拼装文件上传请求参数
*
* @param parameters 普通参数
* @param files 文件参数
* @return 拼装后的结果
*/
private Pair<String, byte[]> assemblyParameters(List<Pair<String, String>> parameters, List<Pair<String, File>> files) throws IOException {
if (!Objects.isNull(files)) {
var build = MultipartEntityBuilder.create();
if (!Objects.isNull(parameters)) {
parameters.forEach(pair -> build.addTextBody(pair.getKey(), pair.getValue()));
}
files.forEach(pair -> {
var key = pair.getKey();
var file = pair.getValue();
if (file.exists() && file.isFile()) {
build.addBinaryBody(key, file);
}
});
var entities = build.build();
try (var out = new ByteArrayOutputStream()) {
entities.writeTo(out);
return Pair.of(entities.getContentType().getValue(), out.toByteArray());
}
} else {
return Pair.of(ContentType.APPLICATION_FORM_URLENCODED.getMimeType(), assemblyParameters(parameters).getBytes());
}
}
/**
* 拼装请求 URI
*
* @param uri 原始 URI
* @param parameters 查询参数
* @return 拼装好的 URI
*/
private URI assemblyUri(String uri, List<Pair<String, String>> parameters) {
if (StringUtils.isBlank(uri)) {
throw new IllegalArgumentException("缺少必要参数:url");
}
if (Objects.isNull(parameters) || parameters.isEmpty()) {
return URI.create(uri);
}
// 拼装查询字符串
return URI.create(String.format("%s%c%s", uri, uri.indexOf(QUERY_MARK) < 0 ? QUERY_MARK : AND_MARK, assemblyParameters(parameters)));
}
/**
* 添加请求 Cookie
*
* @param uri URI
* @param cookies 要添加的 Cookies
* @return URI
*/
@SafeVarargs
private URI putCookies(URI uri, Map<String, List<String>>... cookies) throws IOException {
if (Objects.isNull(uri)) {
throw new IllegalArgumentException("缺少必要的请求参数:uri");
}
if (!Objects.isNull(cookies)) {
for (var cookie : cookies) {
cookieManager.put(uri, cookie);
}
}
return uri;
}
/**
* 发送 GET 请求
*
* @param uri 请求地址
* @return 请求响应结果(http 响应码,http 响应结果)
*/
public Pair<Integer, String> get(String uri) throws IOException, InterruptedException {
return get(uri, null, null, null);
}
/**
* 发送 GET 请求
*
* @param uri 请求地址
* @param queryParameters 请求参数
* @return 请求响应结果(http 响应码,http 响应结果)
*/
public Pair<Integer, String> get(String uri, List<Pair<String, String>> queryParameters) throws IOException, InterruptedException {
return get(uri, queryParameters, null, null);
}
/**
* 发送 GET 请求
*
* @param uri 请求地址
* @param queryParameters 请求参数
* @param headers 请求头
* @param cookies 请求 Cookies
* @return 请求响应结果(http 响应码,http 响应结果)
*/
public Pair<Integer, String> get(
String uri,
List<Pair<String, String>> queryParameters,
Map<String, String> headers,
Map<String, List<String>>[] cookies
) throws IOException, InterruptedException {
var builder = createRequestBuilder(putCookies(assemblyUri(uri, queryParameters), cookies), headers);
var httpClient = this.httpClient;
var request = builder.GET();
var response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString());
return Pair.of(response.statusCode(), response.body());
}
/**
* 使用 application/x-www-form-urlencoded 进行 POST 提交
*
* @param uri 请求 URI
* @param parameters 查询参数(FORM 部分)
* @return 请求响应结果(http 响应码,http 响应结果)
* @throws IOException 异常时抛出
* @throws InterruptedException 异常时抛出
*/
public Pair<Integer, String> post(
String uri,
List<Pair<String, String>> parameters
) throws IOException, InterruptedException {
return post(uri, null, parameters, null, null, null);
}
/**
* 使用 application/x-www-form-urlencoded 进行 POST 提交
*
* @param uri 请求 URI
* @param queryParameters 查询参数(查询字符串部分)
* @param parameters 查询参数(FORM 部分)
* @return 请求响应结果(http 响应码,http 响应结果)
* @throws IOException 异常时抛出
* @throws InterruptedException 异常时抛出
*/
public Pair<Integer, String> post(
String uri,
List<Pair<String, String>> queryParameters,
List<Pair<String, String>> parameters
) throws IOException, InterruptedException {
return post(uri, queryParameters, parameters, null, null, null);
}
/**
* 使用 application/x-www-form-urlencoded 进行 POST 提交
*
* @param uri 请求 URI
* @param queryParameters 查询参数(查询字符串部分)
* @param parameters 查询参数(FORM 部分)
* @param files 查询参数,文件参数
* @param headers 请求头
* @param cookies 请求 Cookies
* @return 请求响应结果(http 响应码,http 响应结果)
* @throws IOException 异常时抛出
* @throws InterruptedException 异常时抛出
*/
public Pair<Integer, String> post(
String uri,
List<Pair<String, String>> queryParameters,
List<Pair<String, String>> parameters,
List<Pair<String, File>> files,
Map<String, String> headers,
Map<String, List<String>>[] cookies
) throws IOException, InterruptedException {
if (Objects.isNull(headers)) {
headers = new HashMap<>(1);
}
var entities = assemblyParameters(parameters, files);
headers.put("Content-Type", entities.getKey());
var builder = createRequestBuilder(putCookies(assemblyUri(uri, queryParameters), cookies), headers);
var httpClient = this.httpClient;
var request = builder.POST(HttpRequest.BodyPublishers.ofByteArray(entities.getValue()));
var response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString());
return Pair.of(response.statusCode(), response.body());
}
/**
* 下载文件,适用于简单的 GET 下载
*
* @param uri 文件下载地址
* @param fileExt 文件扩展名,不提供默认为 tmp
* @return 请求响应结果(http 响应码,http 响应结果)
* @throws IOException 异常时抛出
* @throws InterruptedException 异常时抛出
*/
public Pair<Integer, Path> download(String uri, String fileExt) throws IOException, InterruptedException {
return download(Method.GET, uri, fileExt, null, null, null, null, null);
}
/**
* 下载文件
*
* @param method 请求方式
* @param uri 请求地址
* @param fileExt 文件扩展名
* @param queryParameters 请求参数(查询字符串部分)
* @param parameters 请求参数(POST 请求部分)
* @param files 查询参数,文件参数
* @param headers 请求头信息
* @param cookies 请求 cookies
* @return 请求响应结果(http 响应码,http 响应结果)
* @throws IOException 异常时抛出
* @throws InterruptedException 异常时抛出
*/
public Pair<Integer, Path> download(
Method method,
String uri,
String fileExt,
List<Pair<String, String>> queryParameters,
List<Pair<String, String>> parameters,
List<Pair<String, File>> files,
Map<String, String> headers,
Map<String, List<String>>[] cookies
) throws IOException, InterruptedException {
if (Objects.isNull(headers)) {
headers = new HashMap<>(1);
}
var entities = assemblyParameters(parameters, files);
headers.put("Content-Type", entities.getKey());
var builder = createRequestBuilder(putCookies(assemblyUri(uri, queryParameters), cookies), headers);
var httpClient = this.httpClient;
HttpRequest.Builder request;
switch (method) {
case GET -> request = builder.GET();
case POST -> request = builder.POST(HttpRequest.BodyPublishers.ofByteArray(entities.getValue()));
default -> throw new IllegalStateException("暂不支持的请求方式: " + method.getType());
}
var path = Path.of(
"%s%s_%s.%s".formatted(
FileUtils.getTempDirectoryPath(),
DateTimeKits.currentDay(),
UUID.randomUUID().toString(),
(StringUtils.isBlank(fileExt) ? "tmp" : fileExt)
)
);
var response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofFile(path));
return Pair.of(response.statusCode(), response.body());
}
/* --------------------------- 静态成员区 --------------------------- */
private final static char EQUAL_SIGN = '=';
private final static char AND_MARK = '&';
private final static char QUERY_MARK = '?';
/**
* 请求等待超时时间(单位:秒)
*/
private final static long WAIT_TIMEOUT = 10;
public enum Method {
/**
*
*/
POST("POST"),
/**
*
*/
GET("GET");
final String type;
public String getType() {
return type;
}
Method(String type) {
this.type = type;
}
}
private static class InnerX509TrustManager implements X509TrustManager {
@Override
public void checkClientTrusted(X509Certificate[] x509Certificates, String s) {
}
@Override
public void checkServerTrusted(X509Certificate[] x509Certificates, String s) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
/**
* 创建网络工具
*
* @return 网络工具实例
* @throws NoSuchAlgorithmException 证书处理异常
* @throws KeyManagementException 证书处理异常
*/
public static HttpTool build() throws NoSuchAlgorithmException, KeyManagementException {
return new HttpTool();
}
public static void main(String[] args) throws NoSuchAlgorithmException, KeyManagementException, IOException, InterruptedException {
var url = "https://etax.tianjin.chinatax.gov.cn/WsbsWebBn/wsbsWszmAction_showPdf.do?applicationId=k95oMKvY5GnrX0y6afyxXeRTnybnoa7zq4tPi9AvJ9kdswux79%2BXaGF0w9ZVleKz&zlId=N5nYxzZydYbil0OmVOKYig%3D%3D";
var httpClient = HttpTool.build();
var downloadInfo = httpClient.download(HttpTool.Method.GET, url, "pdf", null, null, null, null, null);
System.out.println(downloadInfo);
System.out.println(OssKits.upload(downloadInfo.getValue().toFile()));
}
}
package com.greatchn.kits;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.Version;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import com.greatchn.kits.exceptions.PackingException;
import org.apache.commons.lang3.StringUtils;
import java.io.IOException;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.TimeZone;
/**
* Jackson 序列化反序列化工具
*
* @author LWang 2023.01.18
* @since 1.0.0
*/
public final class JacksonKits {
private JacksonKits() {
}
private static volatile ObjectMapper OBJECT_MAPPER;
/**
* 将对象转换为 Json 字符串
*
* @param source 要执行转换的对象
* @return 序列化后的 Json 字符串
*/
public static String toJson(Object source) {
if (null == source) {
// null 对象,返回空的 json 对象
return "{}";
}
try {
return getObjectMapper().writeValueAsString(source);
} catch (JsonProcessingException e) {
throw new PackingException(e);
}
}
/**
* 将 Json 字符串转为对象
*
* @param content 要执行反序列化的 Json 字符串
* @param cls 序列化结果类
* @param <T> 结果类泛型
* @return 反序列化结果
*/
public static <T> T toBean(String content, Class<T> cls) {
if (StringUtils.isBlank(content) || cls == null) {
return null;
}
ObjectMapper objectMapper = getObjectMapper();
try {
return objectMapper.readValue(content, cls);
} catch (JsonProcessingException e) {
throw new PackingException(e);
}
}
/**
* @param content 要执行反序列化的 Json 字符串
* @param ref 序列化结果类型
* @param <T> 结果类泛型
* @return 反序列化结果
*/
public static <T> T toBean(String content, TypeReference<T> ref) {
if (StringUtils.isBlank(content) || ref == null) {
return null;
}
ObjectMapper objectMapper = getObjectMapper();
try {
return objectMapper.readValue(content, ref);
} catch (JsonProcessingException e) {
throw new PackingException(e);
}
}
/**
* 将 Json 字符串转为对象列表
*
* @param content 要执行反序列化的 Json 字符串
* @param ref 序列化结果类型
* @param <T> 结果类泛型
* @return 反序列化结果
*/
public static <T> List<T> toList(String content, TypeReference<List<T>> ref) {
if (StringUtils.isBlank(content) || ref == null) {
return null;
}
ObjectMapper objectMapper = getObjectMapper();
try {
return objectMapper.readValue(content, ref);
} catch (JsonProcessingException e) {
throw new PackingException(e);
}
}
/**
* 将 Json 字符串转为 JsonNode 对象,适用于处理无需建立 JavaBean 的临时 Json 字符串处理
*
* @param content 要执行反序列化的 Json 字符串
* @return 反序列化结果
*/
public static JsonNode toJsonNode(String content) {
try {
return getObjectMapper().readTree(content);
} catch (JsonProcessingException e) {
throw new PackingException(e);
}
}
/**
* 获取一个 ObjectMapper 对象
*
* @return ObjectMapper 对象
*/
public static ObjectMapper getObjectMapper() {
if (OBJECT_MAPPER == null) {
synchronized (JacksonKits.class) {
if (OBJECT_MAPPER == null) {
ObjectMapper objectMapper = new ObjectMapper();
// 设置时区
objectMapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));
// 设置时间格式
objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
JavaTimeModule javaTimeModule = new JavaTimeModule();
/*
* 序列化配置,针对java8 时间
*/
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
/*
* 反序列化配置,针对java8 时间
*/
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
/*
* 声明自定义模块,配置double类型序列化配置
*/
SimpleModule module = new SimpleModule("DoubleSerializer", new Version(1, 0, 0, "", "", ""));
// 注意Double和double需要分配配置
JsonSerializer<Double> serializer = new JsonSerializer<>() {
@Override
public void serialize(Double value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
BigDecimal d = BigDecimal.valueOf(value);
gen.writeNumber(d.stripTrailingZeros().toPlainString());
}
@Override
public Class<Double> handledType() {
return Double.class;
}
};
module.addSerializer(Double.class, serializer);
module.addSerializer(double.class, serializer);
/*
* 注册模块
*/
objectMapper
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(javaTimeModule)
.registerModule(module)
.registerModule(new Jdk8Module())
.registerModule(new ParameterNamesModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
OBJECT_MAPPER = objectMapper;
}
}
}
return OBJECT_MAPPER;
}
}
package com.greatchn.kits;
import java.security.SecureRandom;
/**
* 随机数工具,提供生成随机数字和随机字符串功能
* <p>
* 本工具类中的方法都可以使用 Apace Common-lang3 中的工具替代,不过在简单环境下,建议使用本工具类
*
* @author LWang 2023.01.20
* @see org.apache.commons.lang3.RandomUtils
* @see org.apache.commons.lang3.RandomStringUtils
* @since 1.0.0
*/
public final class RandomKits {
private RandomKits() {
}
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final char[] CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*-_=+".toCharArray();
/**
* 获取一个随机整数
*
* @return 随机数
*/
public static int nextInt() {
return SECURE_RANDOM.nextInt();
}
/**
* 获取一个随机整数
*
* @param bound 随机数范围上限
* @return 随机数
*/
public static int nextInt(int bound) {
return SECURE_RANDOM.nextInt(bound);
}
/**
* 获取一个随机整数
*
* @param origin 随机数范围下限
* @param bound 随机数范围上限
* @return 随机数
*/
public static int nextInt(int origin, int bound) {
return SECURE_RANDOM.nextInt(origin, bound);
}
/**
* 获取一个随机长整数
*
* @return 随机数
*/
public static long nextLong() {
return SECURE_RANDOM.nextLong();
}
/**
* 获取一个随机长整数
*
* @param bound 随机数范围上限
* @return 随机数
*/
public static long nextLong(long bound) {
return SECURE_RANDOM.nextLong(bound);
}
/**
* 获取一个随机长整数
*
* @param origin 随机数范围下限
* @param bound 随机数范围上限
* @return 随机数
*/
public static long nextLong(long origin, long bound) {
return SECURE_RANDOM.nextLong(origin, bound);
}
/**
* 获取一个随机单精浮点数
*
* @return 随机数
*/
public static float nextFloat() {
return SECURE_RANDOM.nextFloat();
}
/**
* 获取一个随机单精浮点数
*
* @param bound 随机数范围上限
* @return 随机数
*/
public static float nextFloat(float bound) {
return SECURE_RANDOM.nextFloat(bound);
}
/**
* 获取一个随机单精浮点数
*
* @param origin 随机数范围下限
* @param bound 随机数范围上限
* @return 随机数
*/
public static float nextFloat(float origin, float bound) {
return SECURE_RANDOM.nextFloat(origin, bound);
}
/**
* 获取一个随机双精浮点数
*
* @return 随机数
*/
public static double nextDouble() {
return SECURE_RANDOM.nextDouble();
}
/**
* 获取一个随机双精浮点数
*
* @param bound 随机数范围上限
* @return 随机数
*/
public static double nextDouble(double bound) {
return SECURE_RANDOM.nextDouble(bound);
}
/**
* 获取一个随机双精浮点数
*
* @param origin 随机数范围下限
* @param bound 随机数范围上限
* @return 随机数
*/
public static double nextDouble(double origin, double bound) {
return SECURE_RANDOM.nextDouble(origin, bound);
}
/**
* 生成一个随机字符串,使用数字、字母和常用符号组成,也可以使用 Apache Common-lang3 中的 RandomStringUtils 类中的对应方法,不过那个考虑的情况太多,
* 在使用要求不复杂的情况下,使用本方法即可
*
* @param length 随机字符串长度
* @return 随机字符串
* @see org.apache.commons.lang3.RandomStringUtils
*/
public static String nextString(final int length) {
if (length <= 0) {
throw new IllegalArgumentException("生成随机字符串长度必须大于零!");
}
StringBuilder builder = new StringBuilder(length);
int bound = CHARS.length;
int count = length;
while (count-- != 0) {
builder.append(CHARS[nextInt(bound)]);
}
return builder.toString();
}
}
package com.greatchn.kits;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;
/**
* Yaml 配置文件加载工具
*
* @author LWang 2023.01.29
* @since 1.0.0
*/
public final class YamlKits {
private YamlKits() {
}
/**
* 读取 YAML 配置文件
*
* @param config 配置文件路径
* @param clazz 配置对象映射类
* @param <T> 配置对象泛型
* @return 配置对象
*/
public static <T> T load(String config, Class<T> clazz) {
try (var in = ClassLoader.getSystemResourceAsStream(config)) {
return new Yaml(new Constructor(clazz)).load(in);
} catch (Exception ex) {
throw new IllegalArgumentException("指定的配置文件无效!", ex);
}
}
}
package com.greatchn.kits.exceptions;
import lombok.Getter;
/**
* 异常封装类,业务中的异常可以直接封装为此类对外抛出,在使用的地方在提取实际异常进行异常记录或处理,此异常不包含实际业务意义
*
* @author LWang 2023.01.18
* @since 1.0.0
*/
public class PackingException extends RuntimeException {
/**
* 实际的异常
*/
@Getter
private final Throwable rawCause;
public PackingException(Throwable cause) {
super(cause);
this.rawCause = cause;
}
}
package com.greatchn.kits.oss;
import com.greatchn.kits.HttpTool;
import com.greatchn.kits.JacksonKits;
import com.greatchn.kits.YamlKits;
import lombok.Data;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.http.HttpStatus;
import java.io.File;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
/**
* Oss 工具
*
* @author LWang 2023.02.02
* @since 1.0.0
*/
public final class OssKits {
private OssKits() {
}
@Data
public static class OssConfig {
private String url;
}
@Data
public static class OssResult {
private int code;
private String data;
private String msg;
private String traceId;
}
private final static HttpTool HTTP_TOOL;
private final static OssConfig CONFIG;
static {
try {
HTTP_TOOL = new HttpTool();
CONFIG = YamlKits.load("oss.yaml", OssConfig.class);
} catch (NoSuchAlgorithmException | KeyManagementException e) {
throw new RuntimeException(e);
}
}
/**
* 将文件上传到 OSS
*
* @param file 要上传的文件
* @return OSS 资源 ID
*/
public static String upload(File file) throws IOException, InterruptedException {
var url = "%s/res/support/api/oss/upload".formatted(CONFIG.url);
var result = HTTP_TOOL.post(url, null, null, new ArrayList<>() {{
this.add(Pair.of("file", file));
}}, null, null);
if (!result.getKey().equals(HttpStatus.SC_OK)) {
throw new RuntimeException("上传资源文件异常,服务无响应。");
}
var ossResult = JacksonKits.toBean(result.getValue(), OssResult.class);
assert ossResult != null;
if (ossResult.getCode() != HttpStatus.SC_OK) {
throw new RuntimeException("上传资源文件异常," + ossResult.getMsg() + "," + ossResult.getTraceId());
}
return ossResult.data;
}
public static void main(String[] args) throws IOException, InterruptedException {
upload(new File("d:/result.jpg"));
}
}
package com.greatchn.rpa;
import com.fasterxml.jackson.databind.JsonNode;
import com.greatchn.kits.JacksonKits;
import com.greatchn.kits.RandomKits;
import com.greatchn.rpa.beans.Point;
import com.greatchn.rpa.beans.Rectangle;
import com.greatchn.rpa.config.RpaConfig;
import com.greatchn.rpa.enums.MoveDirection;
import com.greatchn.rpa.exceptions.TimeoutException;
import net.lightbody.bmp.BrowserMobProxyServer;
import net.lightbody.bmp.client.ClientUtil;
import net.lightbody.bmp.core.har.Har;
import net.lightbody.bmp.proxy.CaptureType;
import org.apache.commons.lang3.StringUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeDriverService;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.CapabilityType;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 自动化机器人对象,对 selenium 功能进行封装,方便后期使用
*
* @author LWang 2023.01.28
* @since 1.0.0
*/
public class RpaCore {
/* ------------------------- 对象成员管理 ------------------------- */
/**
* RPA 对象状态
*/
private boolean create = false;
/**
* 机器人配置
*/
private final RpaConfig config;
/**
* 屏幕缩放比例(用于处理定位操作时按比例扩展坐标点)
*/
private final double scale;
/**
* 浏览器驱动,Robot 核心对象
*/
private ChromeDriver driver;
/**
* 获取当前驱动
*
* @return 当前驱动
*/
public ChromeDriver driver() {
return this.driver;
}
/**
* 网络代理对象
*/
private BrowserMobProxyServer proxy;
/**
* 当前浏览器对象右下角位置
*/
private Point browserOffset;
/* ------------------------- 构造方法 ------------------------- */
/**
* 自动化机器人初始化方法
*
* @param config 配置参数
*/
public RpaCore(RpaConfig config) {
this.config = config;
this.scale = config.getScale();
if (config.getRpa().isAutoCreate()) {
this.create();
}
}
/* ------------------------- 核心对象处理 ------------------------- */
/**
* 销毁网络代理
*/
private void destroyProxy() {
this.endHar();
if (!this.proxy.isStopped()) {
this.proxy.stop();
}
}
/**
* 创建网络代理
*/
private void createProxy() {
this.proxy = new BrowserMobProxyServer();
this.proxy.setTrustAllServers(true);
this.proxy.start(this.config.getRpa().getProxy().getPort());
this.proxy.enableHarCaptureTypes(CaptureType.REQUEST_CONTENT, CaptureType.RESPONSE_CONTENT);
this.proxy.setHarCaptureTypes(CaptureType.RESPONSE_CONTENT);
}
/**
* RPA 对象状态
*
* @return 对象状态
*/
public boolean isCreate() {
return this.create;
}
public void stop() {
if (!Objects.isNull(this.proxy)) {
this.destroyProxy();
}
if (!Objects.isNull(this.driver)) {
this.driver.quit();
this.driver = null;
}
this.create = false;
}
/**
* 创建 Driver 对象
*/
public void create() {
if (config.getRpa().getProxy().isEnable()) {
this.createProxy();
}
var options = new ChromeOptions();
options.addArguments(
"--start-maximized",
"disable-infobars",
"--test-type",
"--ignore-certificate-errors"
);
options.setExperimentalOption("excludeSwitches", new String[]{"enable-automation"});
options.setExperimentalOption("prefs", new HashMap<String, Boolean>(3) {
{
this.put("download.prompt_for_download", false);
this.put("download.directory_upgrade", false);
this.put("safebrowsing.enabled", false);
}
});
if (this.config.getRpa().isFullScreen()) {
options.addArguments("--kiosk");
}
if (this.config.getRpa().getProxy().isEnable()) {
var seleniumProxy = ClientUtil.createSeleniumProxy(proxy);
options.setCapability(CapabilityType.PROXY, seleniumProxy);
}
options.setCapability("acceptInsecureCerts", true);
var builder = new ChromeDriverService.Builder();
builder.usingDriverExecutable(this.config.getSelenium().getExe());
var service = builder.build();
this.driver = new ChromeDriver(service, options);
if (this.config.getRpa().getCookies().isClean()) {
this.driver.manage().deleteAllCookies();
}
this.create = true;
}
/**
* 获取当前对象 URL
*
* @return URL
*/
public String url() {
return this.driver.getCurrentUrl();
}
/* ------------------------- 网络代理使用 ------------------------- */
private final AtomicInteger harCount = new AtomicInteger();
private static final int DELTA = 1;
/**
* 停止网络监听统计超时时间(单位:秒)
*/
private static final long QUIT_PERIOD = 1;
/**
* 网络监听超时时间(单位:秒)
*/
private static final long LISTEN_TIME_OUT = 30;
private void endHar() {
try {
if (!Objects.isNull(this.proxy.getHar())) {
this.proxy.endHar();
}
} catch (Exception ignored) {
}
}
/**
* 开启一个新的监听集合
*/
public Har newHar() {
if (!this.config.getRpa().getProxy().isEnable()) {
throw new IllegalStateException("当前 Robot 对象没有开启网络代理!");
}
this.endHar();
return this.proxy.newHar(String.format("robot_har_%d", this.harCount.getAndAdd(DELTA)));
}
/**
* 结束当前网络监听集合
*/
public void stopHar() {
this.proxy.waitForQuiescence(QUIT_PERIOD, QUIT_PERIOD, TimeUnit.SECONDS);
}
/**
* 监听指定 uri 返回内容
*
* @param uri 要监听结果的 uri
* @return 监听响应
*/
public String listen(String uri) {
return this.listen(uri, LISTEN_TIME_OUT);
}
/**
* 监听指定 uri 返回内容
*
* @param uri 要监听结果的 uri
* @param timeout 监听超时时间,(单位:秒)
* @return 监听响应
*/
public String listen(String uri, long timeout) {
final var outTime = System.currentTimeMillis() + timeout * 1000;
try {
while (true) {
var har = this.proxy.getHar();
for (var entry : har.getLog().getEntries()) {
if (!Objects.isNull(entry.getRequest())
&& entry.getRequest().getUrl().contains(uri)
&& !Objects.isNull(entry.getResponse())
&& entry.getResponse().getStatus() == 200) {
return entry.getResponse().getContent().getText();
}
}
if (System.currentTimeMillis() > outTime) {
throw new TimeoutException(String.format("监听 %s 的响应超时;", uri));
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException ignored) {
}
}
} finally {
this.stopHar();
}
}
/**
* 监听指定 uri 返回内容,并将返回内容反序列化为 JsonNode 对象
*
* @param uri 要监听结果的 uri
* @return 监听结果
*/
public JsonNode jsonListen(String uri) {
return this.jsonListen(uri, LISTEN_TIME_OUT);
}
/**
* 监听指定 uri 内容,并将返回内容反序列化为指定对象实例
*
* @param uri 要监听结果的 uri
* @param clazz 反序列化对象类
* @param <T> 反序列化对象类泛型
* @return 监听结果
*/
public <T> T jsonListen(String uri, Class<T> clazz) {
return this.jsonListen(uri, LISTEN_TIME_OUT, clazz);
}
/**
* 监听指定 uri 返回内容,并将返回内容反序列化为 JsonNode 对象
*
* @param uri 要监听结果的 uri
* @param timeout 监听超时时间,(单位:秒)
* @return 监听结果
*/
public JsonNode jsonListen(String uri, long timeout) {
var content = this.listen(uri, timeout);
return JacksonKits.toJsonNode(content);
}
/**
* 监听指定 uri 内容,并将返回内容反序列化为指定对象实例
*
* @param uri 要监听结果的 uri
* @param timeout 监听超时时间,(单位:秒)
* @param clazz 反序列化对象类
* @param <T> 反序列化对象类泛型
* @return 监听结果
*/
public <T> T jsonListen(String uri, long timeout, Class<T> clazz) {
var content = this.listen(uri, timeout);
return JacksonKits.toBean(content, clazz);
}
/* ------------------------- 页面访问管理 ------------------------- */
/**
* 访问指定页面
*
* @param url 页面 URL
*/
public void visit(String url) {
this.driver.get(url);
// 计算浏览器右下角位置
var x = this.driver.executeScript("return window.outerWidth - window.innerWidth");
var y = this.driver.executeScript("return window.outerHeight - window.innerHeight");
if (x instanceof Number pX && y instanceof Number pY) {
browserOffset = new Point(pX.doubleValue() * this.scale, pY.doubleValue() * this.scale);
}
}
/**
* 切换 Frame
*
* @param element 要切换到的 Frame 元素
*/
public void switchToFrame(WebElement element) {
this.driver.switchTo().frame(element);
}
/**
* 切换 Frame
*
* @param by 要切换的 Frame 查询方式
* @param waitTime 元素查找等待时间
*/
public void switchToFrame(By by, long waitTime) {
this.switchToFrame(this.findElement(by, waitTime));
}
/**
* 切换到根 Frame(顶层)
*/
public void switchToParent() {
this.driver.switchTo().parentFrame();
}
/**
* 刷新浏览器
*/
public void refresh() {
this.driver.navigate().refresh();
}
/**
* 从浏览器中移除指定的 cookies
*
* @param cookies 要移除的 cookies
*/
public void removeCookies(String... cookies) {
if (Objects.isNull(this.driver)) {
return;
}
for (var cookie : cookies) {
this.driver.manage().deleteCookieNamed(cookie);
}
}
/* ------------------------- 页面元素查询 ------------------------- */
/**
* 默认元素等待时间 (60 秒)
*/
private static final long DEFAULT_WAIT_TIME = 60;
/**
* 判断元素是否可以点击
*
* @param element 要判断的元素
* @return 点击状态
*/
private boolean canClick(WebElement element) {
try {
var wait = new WebDriverWait(this.driver, Duration.of(5, TimeUnit.SECONDS.toChronoUnit()));
wait.until(ExpectedConditions.elementToBeClickable(element));
return true;
} catch (Exception ignored) {
}
return false;
}
/**
* 等待元素加载并可见
*
* @param by 查询元素
* @param waitTime 等待时间(单位:秒)
*/
public void waitElement(By by, long waitTime) {
try {
var wait = new WebDriverWait(this.driver, Duration.of(waitTime, TimeUnit.SECONDS.toChronoUnit()));
wait.until(ExpectedConditions.presenceOfElementLocated(by));
wait.until(ExpectedConditions.visibilityOfElementLocated(by));
} catch (Exception ex) {
// 如果等待元素超时,改为抛出元素不存在异常
throw new NoSuchElementException(by.toString());
}
}
/**
* 查找元素
*
* @param by 要查找的元素
* @return 查找到的元素
* @throws NoSuchElementException,元素不存在或可用超时抛出
*/
public WebElement findElement(By by) {
return findElement(by, DEFAULT_WAIT_TIME);
}
/**
* 查找元素
*
* @param by 要查找的元素
* @param waitTime 等待元素可用时间(单位:秒)
* @return 查找到的元素
* @throws NoSuchElementException,元素不存在或可用超时抛出
*/
public WebElement findElement(By by, long waitTime) {
this.waitElement(by, waitTime);
return this.driver.findElement(by);
}
/**
* 从已知元素中获取元素
*
* @param element 查询元素范围(祖先级元素)
* @param by 要查询的元素
* @return 查找到的元素
* @throws NoSuchElementException,元素不存在或可用超时抛出
*/
public WebElement findElement(WebElement element, By by) {
return element.findElement(by);
}
/**
* 查找元素集合
*
* @param by 要查找的元素集合
* @return 查找到的元素集合
* @throws NoSuchElementException,元素不存在或可用超时抛出
*/
public List<WebElement> findElements(By by) {
return findElements(by, DEFAULT_WAIT_TIME);
}
/**
* 查找元素集合
*
* @param by 要查找的元素集合
* @param waitTime 等待元素可用的时间(单位:秒)
* @return 查找到的元素集合
* @throws NoSuchElementException,元素不存在或可用超时抛出
*/
public List<WebElement> findElements(By by, long waitTime) {
this.waitElement(by, waitTime);
return this.driver.findElements(by);
}
/**
* 从已知元素中查询元素列表
*
* @param element 查询元素范围(祖先级元素)
* @param by 要查询的元素
* @return 查找到的元素集合
* @throws NoSuchElementException,元素不存在或可用超时抛出
*/
public List<WebElement> findElements(WebElement element, By by) {
return element.findElements(by);
}
/**
* 查找内部文本符合条件的元素
*
* @param innerText 内部文本
* @param by 要查找的元素
* @param waitTime 等待元素可用的时间(单位:秒)
* @return 查找到的元素
* @throws NoSuchElementException,元素不存在或可用超时抛出
*/
public WebElement findElementWithInnerText(String innerText, By by, long waitTime) {
return this.findElementWithInnerText(innerText, this.findElements(by, waitTime));
}
/**
* 在指定范围内查找内部文本符合条件的元素
*
* @param innerText 内部文本
* @param element 查询范围(祖先元素)
* @param by 要查找的元素
* @return 查找到的元素
* @throws NoSuchElementException,元素不存在或可用超时抛出
*/
public WebElement findElementWithInnerText(String innerText, WebElement element, By by) {
return this.findElementWithInnerText(innerText, this.findElements(element, by));
}
/**
* 在元素列表中查找内部文本符合条件的元素
*
* @param innerText 内部文本
* @param elements 要查找的元素列表
* @return 查找到的元素
* @throws NoSuchElementException,元素不存在或可用超时抛出
*/
private WebElement findElementWithInnerText(String innerText, List<WebElement> elements) {
if (elements != null && !elements.isEmpty()) {
for (var element : elements) {
if (this.textEquals(element, innerText)) {
return element;
}
}
}
throw new NoSuchElementException("inner text: " + innerText);
}
/* ------------------------- 页面元素交互 ------------------------- */
/**
* 计算元素矩形点
*
* @return 元素矩形点
*/
public Rectangle sumElementRectangle(WebElement element) {
var result = JacksonKits.toBean(this.executeJavaScript("""
function get_offset_of_parent(win) {
if (win != win.parent) {
var parentOffset = get_offset_of_parent(win.parent);
var frames = parent.document.getElementsByTagName("iframe");
for (var i = 0; i < frames.length; i ++) {
if (win == frames[i].contentWindow) {
// 当前窗口,获取当前窗口偏移量
rect = frames[i].getBoundingClientRect()
return {
x: parentOffset.x + rect.left,
y: parentOffset.y + rect.top
};
}
}
} else {
return {
x: 0,
y: 0
};
}
}
return JSON.stringify(get_offset_of_parent(window));
""", String.class), Point.class);
assert result != null;
var leftTopOffset = new Point(
this.browserOffset.x() + result.x() * this.scale,
this.browserOffset.y() + result.y() * this.scale
);
var elementRect = JacksonKits.toJsonNode(
this.executeJavaScript("return JSON.stringify(arguments[0].getBoundingClientRect())", String.class, element)
);
var lt = new Point(
(leftTopOffset.x() + this.readFromJsonNode(elementRect, "left")) * this.scale,
(leftTopOffset.y() + this.readFromJsonNode(elementRect, "top")) * this.scale
);
var rt = new Point(
(leftTopOffset.x() + this.readFromJsonNode(elementRect, "right")) * this.scale,
(leftTopOffset.y() + this.readFromJsonNode(elementRect, "top")) * this.scale
);
var rb = new Point(
(leftTopOffset.x() + this.readFromJsonNode(elementRect, "right")) * this.scale,
(leftTopOffset.y() + this.readFromJsonNode(elementRect, "bottom")) * this.scale
);
var lb = new Point(
(leftTopOffset.x() + this.readFromJsonNode(elementRect, "left")) * this.scale,
(leftTopOffset.y() + this.readFromJsonNode(elementRect, "bottom")) * this.scale
);
return new Rectangle(lt, rt, rb, lb);
}
/**
* 从 JsonNode 中读取元素,并转换为 double
*
* @param node JsonNode 对象
* @param key JsonNode key
* @return 值
*/
private double readFromJsonNode(JsonNode node, String key) {
return node.get(key).doubleValue();
}
/**
* 执行 JavaScript 脚本,并返回结果
*
* @param script 要执行的脚本
* @param clazz 脚本返回类型
* @param args 脚本参数
* @param <T> 返回类型泛型
* @return 执行结果
*/
public <T> T executeJavaScript(String script, Class<T> clazz, Object... args) {
var result = this.driver.executeScript(script, args);
if (!Objects.isNull(result) && clazz.isInstance(result)) {
return (T) result;
}
return null;
}
public void click(By by) {
this.click(by, DEFAULT_WAIT_TIME);
}
public void click(By by, long waitTime) {
var element = this.findElement(by, waitTime);
this.click(element);
}
public void click(WebElement element) {
if (Objects.isNull(element)) {
return;
}
this.scrollTo(element);
if (!this.config.getRpa().isSimulate() && this.canClick(element)) {
element.click();
} else {
this.moveTo(element);
WinApi.leftClick();
}
}
/**
* 向指定元素输出文本
*
* @param by 元素
* @param text 文本
*/
public void sendKey(By by, String text) {
this.sendKey(by, text, false, DEFAULT_WAIT_TIME);
}
/**
* 向指定元素输出文本
*
* @param by 元素
* @param text 文本
* @param simulate 是否按单字符模拟输入
*/
public void sendKey(By by, String text, boolean simulate) {
this.sendKey(by, text, simulate, DEFAULT_WAIT_TIME);
}
/**
* 向指定元素输出文本
*
* @param by 元素
* @param text 文本
* @param simulate 是否按单字符模拟输入
* @param waitTime 元素查找超时时间(单位:秒)
*/
public void sendKey(By by, String text, boolean simulate, long waitTime) {
var element = this.findElement(by, waitTime);
this.sendKey(element, text, simulate);
}
/**
* 向指定元素输出文本
*
* @param element 元素
* @param text 文本
*/
public void sendKey(WebElement element, String text) {
this.sendKey(element, text, false);
}
/**
* 向指定元素输出文本
*
* @param element 元素
* @param text 文本
* @param simulate 是否按单字符模拟输入
*/
public void sendKey(WebElement element, String text, boolean simulate) {
if (Objects.isNull(element) || Objects.isNull(text) || "".equals(text)) {
return;
}
this.scrollTo(element);
var readonly = element.getAttribute("readonly");
try {
this.driver.executeScript("arguments[0].removeAttribute('readonly')", element);
if (simulate) {
var duration = RandomKits.nextInt(10, 50);
for (var s : text.split("")) {
element.sendKeys(s);
WinApi.delay(duration);
}
} else {
element.sendKeys(text);
}
} finally {
this.driver.executeScript("arguments[0].setAttribute('readonly', arguments[1])", element, readonly);
}
}
/**
* 滚动到指定元素
*
* @param by 元素
*/
public void scrollTo(By by) {
this.scrollTo(by, DEFAULT_WAIT_TIME);
}
/**
* 滚动到指定元素
*
* @param by 元素
* @param waitTime 元素查找超时时间(单位:秒)
*/
public void scrollTo(By by, long waitTime) {
var element = this.findElement(by, waitTime);
this.scrollTo(element);
}
/**
* 滚动到指定元素
*
* @param element 元素
*/
public void scrollTo(WebElement element) {
// 通过 JavaScript 将元素滚动到可见范围
this.driver.executeScript("arguments[0].scrollIntoView()", element);
}
/**
* 将鼠标指针移动到指定元素中
*
* @param by 元素
*/
public void moveTo(By by) {
this.moveTo(by, DEFAULT_WAIT_TIME, MoveDirection.DIAGONAL);
}
/**
* 将鼠标指针移动到指定元素中
*
* @param by 元素
* @param direction 鼠标移动方向
*/
public void moveTo(By by, MoveDirection direction) {
this.moveTo(by, DEFAULT_WAIT_TIME, direction);
}
/**
* 将鼠标指针移动到指定元素中
*
* @param by 元素
* @param waitTime 元素查找超时时间(单位:秒)
*/
public void moveTo(By by, long waitTime) {
this.moveTo(by, waitTime, MoveDirection.DIAGONAL);
}
/**
* 将鼠标指针移动到指定元素中
*
* @param by 元素
* @param waitTime 元素查找超时时间(单位:秒)
* @param direction 鼠标移动方向
*/
public void moveTo(By by, long waitTime, MoveDirection direction) {
var element = this.findElement(by, waitTime);
this.moveTo(element, direction);
}
/**
* 将鼠标指针移动到指定元素中
*
* @param element 元素
*/
public void moveTo(WebElement element) {
this.moveTo(element, MoveDirection.DIAGONAL);
}
/**
* 将鼠标指针移动到指定元素中
*
* @param element 元素
* @param direction 鼠标移动方向
*/
public void moveTo(WebElement element, MoveDirection direction) {
var rect = this.sumElementRectangle(element);
var point = new Point(
(rect.leftTop().x() + rect.rightBottom().x()) / 2,
(rect.leftTop().y() + rect.rightBottom().y()) / 2
);
this.moveTo(point, direction);
}
/**
* 将鼠标移动到指定点
*
* @param point 点
*/
public void moveTo(Point point) {
this.moveTo(point, MoveDirection.DIAGONAL);
}
/**
* 将鼠标移动到指定点
*
* @param point 点
* @param direction 鼠标移动方向
*/
public void moveTo(Point point, MoveDirection direction) {
var currentPoint = WinApi.currentPoint();
var duration = RandomKits.nextLong(100, 1000);
switch (direction) {
case HORIZONTAL -> WinApi.mouseMove(point.x(), currentPoint.y(), duration);
case VERTICAL -> WinApi.mouseMove(currentPoint.x(), point.y(), duration);
}
WinApi.mouseMove(point.x(), point.y(), duration);
}
/**
* 将鼠标拖拽到指定点
*
* @param point 目标点
*/
public void dragTo(Point point) {
var duration = RandomKits.nextLong(100, 1000);
WinApi.dragTo(point.x(), point.y(), duration);
}
/* ------------------------- 辅助方法 ------------------------- */
/**
* 空字符串
*/
private static final String EMPTY_TEXT = "";
/**
* 去除字符串前后空字符,封装下,减少为空判断
*
* @param text 要处理的字符串
* @return 处理后的字符串
*/
private String trim(String text) {
if (text == null) {
return EMPTY_TEXT;
}
return text.trim();
}
/**
* 判断元素内容是否包含指定文本
*
* @param element 要判断的元素
* @param text 要包含的文本
* @return 判断结果
*/
public boolean inElementText(WebElement element, String text) {
if (element == null) {
return false;
}
if (StringUtils.isBlank(text)) {
return true;
}
var innerText = this.trim(element.getText());
return StringUtils.contains(innerText, text);
}
/**
* 判断元素内容是否符合条件
*
* @param element 要判断的元素
* @param innerText 要符合的文本
* @return 比对结果
*/
public boolean textEquals(WebElement element, String innerText) {
if (element == null) {
return false;
}
var text = this.trim(element.getText());
return Objects.equals(text, innerText);
}
/**
* 通过 Windows API 休眠线程(不用考虑异常)
*
* @param ms 休眠时间(单位:秒)
*/
public void sleep(int ms) {
WinApi.delay(ms);
}
}
package com.greatchn.rpa;
import com.greatchn.rpa.beans.Button;
import com.greatchn.rpa.beans.Point;
import java.awt.*;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
/**
* Windows Api 封装
*
* @author LWang 2023.01.28
* @since 1.0.0
*/
public final class WinApi {
private WinApi() {
}
private static final Robot ROBOT;
static {
try {
ROBOT = new Robot();
} catch (AWTException e) {
throw new RuntimeException(e);
}
}
private static final int MINIMUM_DURATION = 100;
private static final int MINIMUM_SLEEP = 50;
public static void mouseMove(double x, double y) {
ROBOT.mouseMove((int) x, (int) y);
}
/**
* 将鼠标指针移动到指定坐标
*
* @param x 横轴
* @param y 纵轴
* @param duration 延时
*/
public static void mouseMove(double x, double y, long duration) {
// 切分坐标
// 读取当前坐标点
var currentPoint = currentPoint();
// 计算移动长宽
var width = x - currentPoint.x();
var height = y - currentPoint.y();
// 每一步移动点
var points = new ArrayList<Point>();
var sleepAmount = 0;
// 最大移动距离(绝对值比较)
var numSteps = (int) (Math.max(Math.abs(width), Math.abs(height)));
if (numSteps > 0) {
// 每一步移动间隔
sleepAmount = (int) (duration / numSteps);
if (duration > MINIMUM_DURATION) {
if (sleepAmount < MINIMUM_DURATION) {
numSteps = (int) (duration / MINIMUM_SLEEP);
sleepAmount = (int) (duration / numSteps);
}
if (numSteps > 0) {
// 计算每一步移动点
var widthSection = width / numSteps;
var heightSection = height / numSteps;
for (var i = 0; i < numSteps; i++) {
currentPoint = new Point(currentPoint.x() + widthSection, currentPoint.y() + heightSection);
points.add(currentPoint);
}
}
}
}
points.add(new Point(x, y));
for (var p : points) {
mouseMove(p.x(), p.y());
ROBOT.delay(sleepAmount);
}
}
/**
* 按下鼠标按键
*
* @param button 按键
*/
public static void mouseDown(Button button) {
ROBOT.mousePress(button.getButton());
}
/**
* 抬起鼠标按键
*
* @param button 按键
*/
public static void mouseUp(Button button) {
ROBOT.mouseRelease(button.getButton());
}
/**
* 鼠标左键点击
*/
public static void leftClick() {
mouseDown(Button.LEFT);
ROBOT.delay(100);
mouseUp(Button.LEFT);
}
/**
* 获取当前鼠标指定定位
*
* @return 当前鼠标定位
*/
public static Point currentPoint() {
var pointInfo = MouseInfo.getPointerInfo().getLocation();
return new Point(pointInfo.getX(), pointInfo.getY());
}
/**
* 拖拽到指定位置
*
* @param x 横坐标
* @param y 纵坐标
* @param duration 延时
*/
public static void dragTo(double x, double y, long duration) {
mouseDown(Button.LEFT);
ROBOT.delay(100);
mouseMove(x, y, duration);
mouseUp(Button.LEFT);
}
/**
* 延时
*
* @param delay 延时(毫秒)
*/
public static void delay(long delay) {
try {
TimeUnit.MILLISECONDS.sleep(delay);
} catch (InterruptedException ignored) {
}
}
public static void main(String[] args) {
mouseMove(0, 0);
mouseMove(1000, 1000, 1000);
}
}
package com.greatchn.rpa.beans;
import java.awt.event.InputEvent;
/**
* 鼠标按键枚举
*
* @author LWang 2023.01.28
* @since 1.0.0
*/
public enum Button {
/**
* 左键
*/
LEFT(InputEvent.BUTTON1_DOWN_MASK),
/**
* 中键
*/
MIDDLE(InputEvent.BUTTON2_DOWN_MASK),
/**
* 右键
*/
RIGHT(InputEvent.BUTTON3_DOWN_MASK);
final int button;
public int getButton() {
return button;
}
Button(int button) {
this.button = button;
}
}
package com.greatchn.rpa.beans;
/**
* 点对象
*
* @param x 横坐标
* @param y 纵坐标
* @author LWang 2023.01.28
* @since 1.0.0
*/
public record Point(double x, double y) {
}
package com.greatchn.rpa.beans;
/**
* 矩形对象
*
* @param leftTop 矩形左上角坐标点
* @param rightTop 矩形右上角坐标点
* @param rightBottom 矩形右下角坐标点
* @param leftBottom 矩形左下角坐标点
* @author LWang 2023.01.28
* @since 1.0.0
*/
public record Rectangle(
Point leftTop,
Point rightTop,
Point rightBottom,
Point leftBottom
) {
}
package com.greatchn.rpa.config;
import com.greatchn.kits.YamlKits;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.io.File;
import java.net.URISyntaxException;
/**
* 机器人配置对象
*
* @author LWang 2023.01.28
* @since 1.0.0
*/
@Data
@ToString
@NoArgsConstructor
public class RpaConfig {
private Selenium selenium;
private Browser browser;
private Rpa rpa;
private Double scale;
/**
* 使用默认配置文件加载配置
*
* @return 配置对象
*/
public static RpaConfig build() {
return build(DEFAULT_CONFIG);
}
/**
* 创建配置对象
*
* @param config 配置文件地址
* @return 配置对象
*/
public static RpaConfig build(String config) {
return YamlKits.load(config, RpaConfig.class);
}
/**
* 默认配置文件
*/
private static final String DEFAULT_CONFIG = "rpa.yaml";
/**
* 默认配置:selenium 默认驱动
*/
private static final String DEFAULT_DRIVER = "driver/chromedriver109.exe";
/**
* 默认配置:CHROME 启动路径
*/
private static final String DEFAULT_CHROME_BIN = null;
/**
* 默认配置:是否开启代理
*/
private static final boolean DEFAULT_ENABLE_PROXY = true;
/**
* 默认配置:代理端口
*/
private static final int DEFAULT_PROXY_PORT = 9099;
/**
* 默认配置:初始化自动清理 Cookies
*/
private static final boolean DEFAULT_CLEAN_COOKIES = true;
/**
* 默认配置:浏览器全屏展示(F11)
*/
private static final boolean DEFAULT_FULL_SCREEN = false;
/**
* 默认配置:交互时,默认滚动到元素
*/
private static final boolean DEFAULT_AUTO_SCROLL = true;
/**
* 默认配置:构造对象是自动创建自动化浏览器实例
*/
private static final boolean DEFAULT_AUTO_CREATE = false;
/**
* 默认配置:模拟鼠标操作
*/
private static final boolean DEFAULT_SIMULATE = true;
@Data
@ToString
@NoArgsConstructor
public static class Selenium {
/**
* Selenium 驱动地址
*/
private String driver = DEFAULT_DRIVER;
public File getExe() {
try {
var exe = new File(ClassLoader.getSystemResource(driver).toURI());
if (!exe.exists() || !exe.isFile()) {
throw new IllegalArgumentException("Selenium 驱动不可用!");
}
return exe;
} catch (URISyntaxException ex) {
throw new IllegalArgumentException("Selenium 驱动不可用!");
}
}
}
@Data
@ToString
@NoArgsConstructor
public static class Browser {
/**
* Chrome 启动路径
*/
private String chrome = DEFAULT_CHROME_BIN;
}
@Data
@ToString
@NoArgsConstructor
public static class Rpa {
private Proxy proxy;
private Cookies cookies;
/**
* Selenium 创建时,是否全屏浏览器
*/
private boolean fullScreen = DEFAULT_FULL_SCREEN;
/**
* Selenium 交互元素时,是否自动滚动到元素可见位置
*/
private boolean autoScroll = DEFAULT_AUTO_SCROLL;
/**
* 构造 RPA 对象时,是否自动创建 Selenium 对象
*/
private boolean autoCreate = DEFAULT_AUTO_CREATE;
/**
* 是否启用基于 Windows API 的模拟交互操作
*/
private boolean simulate = DEFAULT_SIMULATE;
}
@Data
@ToString
@NoArgsConstructor
public static class Proxy {
/**
* 是否开启网络代理
*/
private boolean enable = DEFAULT_ENABLE_PROXY;
/**
* 网络代理端口
*/
private int port = DEFAULT_PROXY_PORT;
}
@Data
@ToString
@NoArgsConstructor
public static class Cookies {
/**
* 构造 Selenium 对象时,自动清理 cookies
*/
private boolean clean = DEFAULT_CLEAN_COOKIES;
}
}
package com.greatchn.rpa.enums;
/**
* 鼠标移动方向枚举
*
* @author LWang 2023.01.28
* @since 1.0.0
*/
public enum MoveDirection {
/**
* 对角线移动
*/
DIAGONAL,
/**
* 横向移动
*/
HORIZONTAL,
/**
* 纵向移动
*/
VERTICAL
}
package com.greatchn.rpa.exceptions;
import lombok.Getter;
/**
* 超时异常
*
* @author LWang 2023.01.28
* @since 1.0.0
*/
public class TimeoutException extends RuntimeException {
@Getter
private final String message;
public TimeoutException(String message) {
super(message);
this.message = message;
}
}
package com.greatchn.yzh;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* RPA 线程工长
*
* @author LWang 2023.02.03
* @since 1.0.0
*/
class RpaThreadFactory implements ThreadFactory {
private static final String DEFAULT_NAME = "Worker";
private static final String MINUS_SIGN = "-";
private static final int SLEEP_TIMES = 500;
private final AtomicInteger nextId = new AtomicInteger(1);
private final ThreadGroup threadGroup;
private final String prefix;
private final boolean daemon;
/**
* @param prefix 线程前缀
* @param daemon 是否守护线程
*/
RpaThreadFactory(String prefix, boolean daemon) {
if (StringUtils.isBlank(prefix)) {
prefix = DEFAULT_NAME;
}
if (prefix.endsWith(MINUS_SIGN)) {
prefix = prefix.substring(0, prefix.length() - 1);
}
this.prefix = prefix;
this.daemon = daemon;
threadGroup = new ThreadGroup(prefix);
}
@Override
public Thread newThread(@NotNull Runnable task) {
String threadName = String.format("%s-%d", this.prefix, this.nextId.getAndIncrement());
Thread thread = new Thread(this.threadGroup, task, threadName, 0);
thread.setDaemon(this.daemon);
return thread;
}
/**
* 创建多个线程,并执行
*
* @param runnable 线程
* @param amount 数量
*/
public void runThreads(Runnable runnable, int amount) {
runThreads(runnable, amount, true);
}
public void runThreads(Runnable runnable, int amount, boolean slowly) {
if (runnable == null) {
return;
}
if (amount <= 0) {
amount = 1;
}
for (int i = 0; i < amount; i++) {
this.newThread(runnable).start();
// 缓步创建
if (slowly) {
try {
TimeUnit.MILLISECONDS.sleep(SLEEP_TIMES + ThreadLocalRandom.current().nextInt(SLEEP_TIMES));
} catch (InterruptedException ie) {
// nothing to do
}
}
}
}
}
\ No newline at end of file
package com.greatchn.yzh;
import com.greatchn.etax.monitor.Monitor;
import com.greatchn.etax.tianjin.Tianjin;
import com.greatchn.kits.EnvironmentToolkit;
import com.greatchn.kits.HashKits;
import com.greatchn.rpa.config.RpaConfig;
import com.greatchn.yzh.db.Db;
import lombok.extern.slf4j.Slf4j;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 云账户 RPA 任务处理器
*
* @author LWang 2023.02.03
* @since 1.0.0
*/
@Slf4j
public final class TaskExecutor {
private final Db db;
private final Tianjin tianjin;
private final Monitor monitor;
private final String instanceID;
private final String ip;
public TaskExecutor() {
instanceID = "%s_java_rpa".formatted(HashKits.sha256(EnvironmentToolkit.getMac().getBytes(StandardCharsets.UTF_8)));
ip = EnvironmentToolkit.getLocalIp();
db = new Db();
tianjin = new Tianjin(RpaConfig.build("rpa.yaml"));
monitor = new Monitor();
// 心跳
var executorService = new ScheduledThreadPoolExecutor(1, new RpaThreadFactory("RPA", true));
var schedule = executorService.scheduleWithFixedDelay(this::doHeartbeat, 1, 3, TimeUnit.SECONDS);
// 任务处理
while (monitor.doNext()) {
// 读取任务,并执行任务锁处理
System.out.println("TODO 执行任务!");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException ignored) {
}
}
log.error("停止服务!");
tianjin.shutdown();
log.error("停止心跳定时任务!");
schedule.cancel(true);
executorService.shutdownNow();
log.error("销毁监控服务!");
monitor.exit();
System.exit(0);
}
private void doHeartbeat() {
try {
log.debug("实例 {} 心跳", instanceID);
if (!db.execute(conn -> {
try (
var query = conn.prepareStatement("select `id` from `runtime_service_instances` where `id` = ?");
var update = conn.prepareStatement("update `runtime_service_instances` set `heartbeat_time` = now() where `id` = ?");
var insert = conn.prepareStatement("insert into `runtime_service_instances` (`id`, `ip`, `heartbeat_time`) values(?, ?, now())")
) {
var isUpdate = false;
// 查询心跳实例记录是否存在
query.setString(1, instanceID);
try (var rs = query.executeQuery()) {
isUpdate = rs.next();
}
if (isUpdate) {
// 如果心跳实例记录存在,则更新心跳记录
update.setString(1, instanceID);
update.execute();
} else {
// 如果心跳实例记录不存在,则新增心跳记录
insert.setString(1, instanceID);
insert.setString(2, ip);
insert.execute();
}
return true;
}
})) {
log.warn("进行心跳记录失败!");
}
} catch (Exception e) {
log.error("实例 {} 心跳异常", instanceID, e);
}
}
}
package com.greatchn.yzh.db;
import com.greatchn.kits.YamlKits;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import java.sql.Connection;
import java.sql.SQLException;
/**
* 数据库管理工具
*
* @author LWang 2023.02.03
* @since 1.0.0
*/
@Slf4j
public class Db {
private final HikariDataSource dataSource;
public Db() {
dataSource = new HikariDataSource(YamlKits.load("database.yaml", HikariConfig.class));
}
/**
* 执行 DB 事务性操作
*
* @param executor 事务执行对象
* @param <T> 返回值泛型
* @return 事务执行结果
* @throws SQLException 数据库操作异常抛出
*/
public <T> T execute(IExecutor<T> executor) throws SQLException {
try (var conn = dataSource.getConnection()) {
var savePoint = conn.setSavepoint();
try {
var result = executor.execute(conn);
conn.commit();
return result;
} catch (SQLException e) {
conn.rollback(savePoint);
}
}
throw new SQLException("SQL 执行者没有正常运行!");
}
/**
* 执行 DB 查询性操作
*
* @param executor 查询执行对象
* @param <T> 返回值泛型
* @return 查询结果
* @throws SQLException 数据库操作异常抛出
*/
public <T> T query(IExecutor<T> executor) throws SQLException {
try (var conn = dataSource.getConnection()) {
return executor.execute(conn);
}
}
public interface IExecutor<T> {
/**
* 执行数据库操作
*
* @param conn 数据库连接对象
* @return 执行结果
* @throws SQLException 数据处理异常抛出
*/
T execute(Connection conn) throws SQLException;
}
}
maximumPoolSize: 2
minimumIdle: 1
username: admin
password: "!SwK#2@hzBuzxWiL"
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: "jdbc:mysql://192.168.10.102:3306/e_tax_cloud_account_v3_test?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&autoReconnect=true&autoReconnectForPools=true&pinGlobalTxToPhysicalConnection=true"
autoCommit: false
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
domain: https://etax.tianjin.chinatax.gov.cn/
enterpriseMainUrl: https://etax.tianjin.chinatax.gov.cn/apps/view/main.html?ready=true
enterpriseHomeUri: /apps/view/main.html
humanMainUrl: https://etax.tianjin.chinatax.gov.cn/apps/viewZrr/main.html?ready=true
humanHomeUri: /apps/viewZrr/main.html
<?xml version="1.0" encoding="UTF-8"?>
<!-- 日志级别从低到高分为 TRACE < DEBUG < INFO < WARN < ERROR < FATAL,如果设置为WARN,则低于WARN的信息都不会输出 -->
<!-- scan:当此属性设置为true时,配置文件如果发生改变,将会被重新加载,默认值为true -->
<!-- scanPeriod:设置监测配置文件是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒。当scan为true时,此属性生效。默认的时间间隔为1分钟。 -->
<!-- debug:当此属性设置为true时,将打印出logback内部日志信息,实时查看logback运行状态。默认值为false。 -->
<configuration scan="true" scanPeriod="10 seconds">
<contextName>logback</contextName>
<!-- name的值是变量的名称,value的值时变量定义的值。通过定义的值会被插入到logger上下文中。定义变量后,可以使“${}”来使用变量。 -->
<property name="log.path" value="../logs/greatchn/rpa/api"/>
<!--输出到控制台-->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>DEBUG</level>
</filter>
<encoder>
<Pattern>
[lineNumber-%L] [traceId-%X{traceId}] %date{yyyy-MM-dd HH:mm:ss} | %highlight(%-5level) |
%boldYellow(%thread) | %boldGreen(%logger) | %msg%n
</Pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- <logger name="com.zaxxer.hikari.pool.HikariPool" level="DEBUG" >-->
<!-- <appender-ref ref="INFO_FILE"/>-->
<!-- </logger>-->
<root level="ERROR">
<appender-ref ref="CONSOLE"/>
</root>
<root level="OFF">
<appender-ref ref="io.netty"/>
</root>
</configuration>
\ No newline at end of file
url: http://39.105.29.63
\ No newline at end of file
selenium:
driver: driver/chromedriver109.exe
browser:
chrome: C:\Program Files\Google\Chrome\Application\chrome.exe
rpa:
proxy:
enable: true
port: 30001
cookies:
clean: true
fullScreen: false
autoScroll: true
autoCreate: true
simulate: true
scale: 1.00
\ No newline at end of file
url: http://dev.htyfw.com.cn:7070
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment