计划任务编辑详情前后端适配
This commit is contained in:
@@ -29,7 +29,8 @@ import quant.rich.emoney.util.SpringContextHolder;
|
||||
import okhttp3.OkHttpClient;
|
||||
|
||||
/**
|
||||
* 益盟操盘手基本请求客户端,提供基本功能
|
||||
* 益盟操盘手基本请求客户端,提供基本功能,如登录和请求。
|
||||
* <p>本类一般只提供静态方法,故不能也不该实例化本类。具体请求内容需要自己封装。整个系统共用一套鉴权,所以不要复制本例
|
||||
* <p><b>请求头顺序</b></p>
|
||||
* <p>
|
||||
* <ul>
|
||||
@@ -54,12 +55,13 @@ import okhttp3.OkHttpClient;
|
||||
@Data
|
||||
@Slf4j
|
||||
@Accessors(chain = true)
|
||||
public class EmoneyClient implements Cloneable {
|
||||
public class EmoneyClient {
|
||||
|
||||
private static final String MBS_URL = "https://mbs.emoney.cn/";
|
||||
private static final String STRATEGY_URL = "https://mbs.emoney.cn/strategy/";
|
||||
private static final String LOGIN_URL = "https://emapp.emoney.cn/user/auth/login";
|
||||
private static final String RELOGIN_URL = "https://emapp.emoney.cn/user/auth/ReLogin";
|
||||
private static final String STRATEGY_X_PROTOCOL_ID = "9400";
|
||||
private static final String LOGIN_X_PROTOCOL_ID = "user%2Fauth%2Flogin";
|
||||
private static final String RELOGIN_X_PROTOCOL_ID = "user%2Fauth%2FReLogin";
|
||||
|
||||
@@ -69,24 +71,27 @@ public class EmoneyClient implements Cloneable {
|
||||
/**
|
||||
* 根据 protocolId 返回 URL
|
||||
* <p>益盟操盘手对于不同的 protocolId 有不同的 URL 负责。在进行某类请求之前,请先通过调试 APP 进行确认,否则可能无法获取到相应内容
|
||||
*
|
||||
* @param protocolId
|
||||
* @return
|
||||
* @return 对应的 URL,当所给 protocolId 为 null 或非 String/Integer 类型时,返回为 null,使用前需要检查
|
||||
*/
|
||||
private static String getUrlByProtocolId(Serializable protocolId) {
|
||||
String strProtocolId;
|
||||
if (protocolId instanceof Integer intProtocolId) {
|
||||
switch (intProtocolId) {
|
||||
case 9400: return STRATEGY_URL;
|
||||
default: return MBS_URL;
|
||||
}
|
||||
strProtocolId = String.valueOf(intProtocolId);
|
||||
}
|
||||
else if (protocolId instanceof String strProtocolId) {
|
||||
switch (strProtocolId) {
|
||||
else if (protocolId instanceof String s) {
|
||||
strProtocolId = s;
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
switch (strProtocolId) {
|
||||
case STRATEGY_X_PROTOCOL_ID: return STRATEGY_URL;
|
||||
case LOGIN_X_PROTOCOL_ID: return LOGIN_URL;
|
||||
case RELOGIN_X_PROTOCOL_ID: return RELOGIN_URL;
|
||||
default: return null;
|
||||
}
|
||||
default: return MBS_URL;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +131,9 @@ public class EmoneyClient implements Cloneable {
|
||||
return requestResponseInspectService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 不允许外部实例化对象
|
||||
*/
|
||||
private EmoneyClient() {}
|
||||
|
||||
/**
|
||||
@@ -142,7 +150,6 @@ public class EmoneyClient implements Cloneable {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 使用系统管理的用户名密码登录
|
||||
* <p>建议仅在调试时使用,其他情况请用 {@code loginWithManaged()}</p>
|
||||
@@ -182,7 +189,7 @@ public class EmoneyClient implements Cloneable {
|
||||
* 触发重登陆验证
|
||||
* @return
|
||||
*/
|
||||
public static Boolean relogin() {
|
||||
public static Boolean reloginCheck() {
|
||||
RequestInfo requestInfo = getDefaultRequestInfo();
|
||||
ObjectNode reloginObject = requestInfo.getReloginObject();
|
||||
if (reloginObject == null) {
|
||||
@@ -250,11 +257,9 @@ public class EmoneyClient implements Cloneable {
|
||||
*/
|
||||
private static Boolean login(ObjectNode formObject) {
|
||||
try {
|
||||
//OkHttpClient okHttpClient = new OkHttpClient();
|
||||
RequestInfo requestInfo = getDefaultRequestInfo();
|
||||
OkHttpClient okHttpClient = OkHttpClientProvider.getInstance();
|
||||
MediaType type = MediaType.parse("application/json");
|
||||
//type.charset(StandardCharsets.UTF_8);
|
||||
byte[] content = formObject.toString().getBytes("utf-8");
|
||||
RequestBody body = RequestBody.create(
|
||||
content, type);
|
||||
@@ -273,7 +278,7 @@ public class EmoneyClient implements Cloneable {
|
||||
.header("Authorization", "")
|
||||
.header("X-Android-Agent", requestInfo.getXAndroidAgent())
|
||||
.header("Emapp-ViewMode", requestInfo.getEmappViewMode());
|
||||
//.header("User-Agent", requestInfo.getOkHttpUserAgent())
|
||||
//此处 User-Agent 由 ByteBuddy 拦截修改
|
||||
|
||||
Request request = requestBuilder.build();
|
||||
|
||||
@@ -312,8 +317,10 @@ public class EmoneyClient implements Cloneable {
|
||||
/**
|
||||
* 获取基本返回 Base_Response
|
||||
*
|
||||
* @param <T>
|
||||
* @param nanoRequest
|
||||
* @param <T> 请求类型泛型
|
||||
* @param nanoRequest 请求
|
||||
* @param xProtocolId 请求对应的 Protocol ID
|
||||
* @param xRequestId 视具体请求而定。可能是 null,也可能是 String.valueOf(System.currentTimeMillis())
|
||||
* @return
|
||||
*/
|
||||
protected static <T extends MessageNano> BaseResponse.Base_Response post(
|
||||
@@ -355,8 +362,8 @@ public class EmoneyClient implements Cloneable {
|
||||
.header("EM-Sign", EncryptUtils.getEMSign(content, "POST", xProtocolId.toString()))
|
||||
.header("Authorization", requestInfo.getAuthorization())
|
||||
.header("X-Android-Agent", requestInfo.getXAndroidAgent())
|
||||
.header("Emapp-ViewMode", requestInfo.getEmappViewMode())
|
||||
;
|
||||
.header("Emapp-ViewMode", requestInfo.getEmappViewMode());
|
||||
//此处 User-Agent 由 ByteBuddy 拦截修改
|
||||
|
||||
Request request = requestBuilder.build();
|
||||
|
||||
@@ -377,15 +384,20 @@ public class EmoneyClient implements Cloneable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据指定 clazz 获取返回
|
||||
*
|
||||
* @param <T>
|
||||
* @param <U>
|
||||
* @param nanoRequest
|
||||
* @param clazz
|
||||
* 请求并返回
|
||||
* @param <T> 请求类型泛型
|
||||
* @param <U> 返回类型泛型
|
||||
* @param nanoRequest 请求
|
||||
* @param clazz 返回类型
|
||||
* @param xProtocolId 请求对应的 Protocol ID
|
||||
* @param xRequestId 视具体请求而定。可能是 null,也可能是 String.valueOf(System.currentTimeMillis())
|
||||
* @return
|
||||
*/
|
||||
public static <T extends MessageNano, U extends MessageNano> U post(T nanoRequest, Class<U> clazz, Serializable xProtocolId, Serializable xRequestId) {
|
||||
public static <T extends MessageNano, U extends MessageNano> U post(
|
||||
T nanoRequest,
|
||||
Class<U> clazz,
|
||||
Serializable xProtocolId,
|
||||
Serializable xRequestId) {
|
||||
|
||||
BaseResponse.Base_Response baseResponse;
|
||||
try {
|
||||
@@ -415,5 +427,5 @@ public class EmoneyClient implements Cloneable {
|
||||
throw new EmoneyRequestException("执行 emoney " + nanoRequest.getClass().getSimpleName() + " 请求/返回时出现错误", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import quant.rich.emoney.util.SpringContextHolder;
|
||||
|
||||
/**
|
||||
* OkHttpClient 提供器
|
||||
* <p>
|
||||
* 此处提供的 OkHttpClient 方便使用平台配置的代理,方便是否启用 HTTPS 证书认证等
|
||||
* @see quant.rich.emoney.entity.config.ProxyConfig
|
||||
* @see okhttp3.internal.http.BridgeInterceptor
|
||||
*/
|
||||
|
||||
@@ -57,7 +57,7 @@ public class RequireAuthAndProxyAspect {
|
||||
throw new RuntimeException("鉴权登录失败");
|
||||
}
|
||||
}
|
||||
else if (!EmoneyClient.relogin()) {
|
||||
else if (!EmoneyClient.reloginCheck()) {
|
||||
throw new RuntimeException("检查重鉴权失败");
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.headers(headers -> headers.cacheControl(cache -> cache.disable()))
|
||||
.headers(headers -> headers
|
||||
.cacheControl(cache -> cache.disable())
|
||||
.frameOptions(f -> f.sameOrigin())
|
||||
)
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/favicon.ico").permitAll()
|
||||
|
||||
@@ -29,21 +29,8 @@ public class ErrorPageController implements ErrorController {
|
||||
@GetMapping(value = ERROR_PATH)
|
||||
@PostMapping(value = ERROR_PATH)
|
||||
public String errorHtml(HttpServletRequest request) {
|
||||
HttpStatus status = getStatus(request);
|
||||
String prefix = "error/";
|
||||
|
||||
return prefix + "error_400";
|
||||
|
||||
// switch (status) {
|
||||
// case BAD_REQUEST:
|
||||
// return prefix + "error_400";
|
||||
// case NOT_FOUND:
|
||||
// return prefix + "error_404";
|
||||
// case METHOD_NOT_ALLOWED:
|
||||
// return prefix + "error_405";
|
||||
// default:
|
||||
// return prefix + "error_5xx";
|
||||
// }
|
||||
}
|
||||
|
||||
@GetMapping(value = ERROR_PATH, produces = "application/json")
|
||||
|
||||
@@ -13,6 +13,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import com.google.code.kaptcha.impl.DefaultKaptcha;
|
||||
|
||||
import quant.rich.emoney.service.AuthService;
|
||||
import quant.rich.emoney.util.ArithmeticCaptchaGen;
|
||||
import quant.rich.emoney.util.ArithmeticCaptchaGen.Captcha;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/captcha")
|
||||
@@ -20,13 +22,13 @@ public class KaptchaController extends BaseController {
|
||||
|
||||
@Autowired
|
||||
DefaultKaptcha kaptcha;
|
||||
|
||||
@GetMapping(value = "/get", produces = MediaType.IMAGE_JPEG_VALUE)
|
||||
public byte[] getCaptcha() throws Exception {
|
||||
String createText = kaptcha.createText();
|
||||
|
||||
Captcha captcha = ArithmeticCaptchaGen.generate();
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
session.setAttribute(AuthService.CAPTCHA, createText);
|
||||
ImageIO.write(kaptcha.createImage(createText), "jpg", os);
|
||||
session.setAttribute(AuthService.CAPTCHA, captcha.answer());
|
||||
ImageIO.write(kaptcha.createImage(captcha.expr()), "jpg", os);
|
||||
byte[] result = os.toByteArray();
|
||||
os.close();
|
||||
return result;
|
||||
|
||||
@@ -2,20 +2,29 @@ package quant.rich.emoney.controller.manage;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import quant.rich.emoney.controller.common.UpdateBoolServiceController;
|
||||
import quant.rich.emoney.entity.sqlite.Plan;
|
||||
import quant.rich.emoney.enums.PlanType;
|
||||
import quant.rich.emoney.exception.PageNotFoundException;
|
||||
import quant.rich.emoney.exception.RException;
|
||||
import quant.rich.emoney.interfaces.IQueryableEnum;
|
||||
import quant.rich.emoney.pojo.dto.LayPageReq;
|
||||
@@ -31,6 +40,9 @@ public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
|
||||
@Autowired
|
||||
PlanService planService;
|
||||
|
||||
@Autowired
|
||||
ObjectMapper objectMapper;
|
||||
|
||||
@GetMapping({"", "/", "/index"})
|
||||
public String index() {
|
||||
return "/admin/v1/manage/plan/index";
|
||||
@@ -48,6 +60,60 @@ public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
|
||||
return super.getOne(planId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用编辑接口,跳转对应模式的编辑页
|
||||
* @param planId
|
||||
* @param ra
|
||||
* @return
|
||||
*/
|
||||
@GetMapping("/edit")
|
||||
public String edit(Integer planId, RedirectAttributes ra) {
|
||||
Plan plan = null;
|
||||
if (planId != null) {
|
||||
plan = planService.getById(planId);
|
||||
}
|
||||
if (plan == null) {
|
||||
plan = new Plan();
|
||||
}
|
||||
if (plan.getPlanId() != null) {
|
||||
ra.addAttribute("planId", plan.getPlanId());
|
||||
}
|
||||
if (plan.getPlanType() == null || plan.getPlanType() == PlanType.SINGLE_INDEX) {
|
||||
return "redirect:editSingleIndex";
|
||||
}
|
||||
else if (plan.getPlanType() == PlanType.MULTI_INDEX) {
|
||||
return "redirect:editMultiIndex";
|
||||
}
|
||||
else if (plan.getPlanType() == PlanType.STOCK_STRATEGY) {
|
||||
return "redirect:editStockStrategy";
|
||||
}
|
||||
throw new PageNotFoundException();
|
||||
}
|
||||
|
||||
@GetMapping("/edit{planTypeString}")
|
||||
public String editPlanType(@PathVariable String planTypeString, Integer planId) {
|
||||
Optional<PlanType> optional = tryParseEnum(PlanType.class, planTypeString);
|
||||
if (optional.isEmpty()) {
|
||||
throw new PageNotFoundException();
|
||||
}
|
||||
Plan plan = null;
|
||||
if (planId != null) {
|
||||
plan = planService.getById(planId);
|
||||
}
|
||||
if (plan == null) {
|
||||
plan = new Plan();
|
||||
}
|
||||
String planJsonString = "{}";
|
||||
try {
|
||||
planJsonString = objectMapper.writeValueAsString(plan);
|
||||
}
|
||||
catch (Exception e) {}
|
||||
request.setAttribute("plan", plan);
|
||||
request.setAttribute("planType", optional.get().toString());
|
||||
request.setAttribute("planJsonString", planJsonString);
|
||||
return "/admin/v1/manage/plan/edit" + planTypeString;
|
||||
}
|
||||
|
||||
@PostMapping("/save")
|
||||
@ResponseBody
|
||||
public R<?> save(@RequestBody Plan plan) {
|
||||
@@ -119,5 +185,63 @@ public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
|
||||
return note;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将大驼峰转换为大写+下划线分隔形式
|
||||
* @param s
|
||||
* @return
|
||||
*/
|
||||
public static String toUpperSnake(String s) {
|
||||
if (s == null) return null;
|
||||
s = s.trim();
|
||||
if (s.isEmpty()) return s;
|
||||
|
||||
// 先把空格/连字符这类统一成下划线(可按需扩展)
|
||||
s = s.replace('-', '_').replace(' ', '_');
|
||||
|
||||
// 1) fooBar / foo1Bar -> foo_Bar / foo1_Bar
|
||||
s = s.replaceAll("([a-z0-9])([A-Z])", "$1_$2");
|
||||
// 2) HTTPServer -> HTTP_Server(在 "P" 和 "S" 之间插)
|
||||
s = s.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2");
|
||||
|
||||
// 合并多余下划线
|
||||
s = s.replaceAll("_+", "_");
|
||||
// 去掉首尾下划线
|
||||
s = s.replaceAll("^_+|_+$", "");
|
||||
|
||||
return s.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public static <E extends Enum<E>> Optional<E> tryParseEnum(Class<E> enumClass, String input) {
|
||||
if (enumClass == null || input == null) return Optional.empty();
|
||||
|
||||
String raw = input.trim();
|
||||
if (raw.isEmpty()) return Optional.empty();
|
||||
|
||||
// 1) 直接按原样尝试(允许用户本来就传了正确的常量名)
|
||||
E e = tryValueOf(enumClass, raw);
|
||||
if (e != null) return Optional.of(e);
|
||||
|
||||
// 2) 尝试 UPPER_SNAKE_CASE
|
||||
String upperSnake = toUpperSnake(raw);
|
||||
e = tryValueOf(enumClass, upperSnake);
|
||||
if (e != null) return Optional.of(e);
|
||||
|
||||
// 3) 兜底:忽略大小写匹配(成本 O(n),但很稳)
|
||||
for (E constant : enumClass.getEnumConstants()) {
|
||||
if (constant.name().equalsIgnoreCase(raw) || constant.name().equalsIgnoreCase(upperSnake)) {
|
||||
return Optional.of(constant);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static <E extends Enum<E>> E tryValueOf(Class<E> enumClass, String name) {
|
||||
try {
|
||||
return Enum.valueOf(enumClass, name);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package quant.rich.emoney.crawler;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
|
||||
/**
|
||||
* 益盟爬虫爬取成功与否及对应的爬虫配置,方便重新爬取
|
||||
* @author Barry
|
||||
*
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain=true)
|
||||
public class EmoneyCrawlerResult<T> {
|
||||
|
||||
private T callable;
|
||||
private boolean success;
|
||||
private Exception exception;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package quant.rich.emoney.crawler;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Data;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import nano.CandleStickNewResponse.CandleStickNew_Response.CandleStick;
|
||||
import nano.CandleStickNewWithIndexExResponse.CandleStickNewWithIndexEx_Response;
|
||||
import nano.CandleStickNewWithIndexExResponse.CandleStickNewWithIndexEx_Response.IndLine;
|
||||
import nano.CandleStickRequest.CandleStick_Request;
|
||||
import nano.CandleStickWithIndexRequest.CandleStickWithIndex_Request;
|
||||
import nano.CandleStickWithIndexRequest.CandleStickWithIndex_Request.IndexInfo;
|
||||
import nano.IndexCalcNewExResponse.IndexCalcNewEx_Response.outputline;
|
||||
import quant.rich.emoney.client.EmoneyClient;
|
||||
import quant.rich.emoney.entity.postgre.EmoneyIndex;
|
||||
import quant.rich.emoney.enums.StockSpan;
|
||||
import quant.rich.emoney.exception.EmoneyRequestException;
|
||||
import quant.rich.emoney.util.DateUtils;
|
||||
import quant.rich.emoney.util.StockCodeUtils;
|
||||
|
||||
/**
|
||||
* 执行爬虫时最终被调用的方法。该方法使用 2422(多指标、带 K 线)更新指标数据
|
||||
*/
|
||||
@Data
|
||||
@Slf4j
|
||||
public class EmoneyIndexCallable implements Callable<EmoneyCrawlerResult<EmoneyIndexCallable>> {
|
||||
|
||||
private static final int LIMIT_SIZE = 250;
|
||||
|
||||
private EmoneyCrawlerResult<EmoneyIndexCallable> result;
|
||||
|
||||
private String tsCode;
|
||||
|
||||
private StockSpan stockSpan;
|
||||
|
||||
private Long beginPosition;
|
||||
|
||||
private Long totalNeeds;
|
||||
|
||||
private Function<List<EmoneyIndex>, Boolean> func;
|
||||
|
||||
private IndexInfo[] indexInfos;
|
||||
|
||||
@Setter(AccessLevel.PRIVATE)
|
||||
private String indexNames;
|
||||
|
||||
public EmoneyIndexCallable setIndexInfos(IndexInfo[] indexInfos) {
|
||||
this.indexInfos = indexInfos;
|
||||
indexNames = Arrays.stream(indexInfos)
|
||||
.map(IndexInfo::getIndexName)
|
||||
.collect(Collectors.joining(", "));
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* 初始化一个 Callable
|
||||
* @param tsCode
|
||||
* @param beginPosition 起始位置,一般为当日或当时,如 20230921L
|
||||
* @param stockSpan 数据粒度
|
||||
* @param totalNeeds 经过计算需要的总数据数
|
||||
* @param func 消费 {@code List<EmoneyIndex>} 的方法,返回的布尔值辅助判断本次调用是否成功
|
||||
* @param indexInfos 指标类型数组
|
||||
*/
|
||||
public EmoneyIndexCallable(
|
||||
String tsCode,
|
||||
Long beginPosition,
|
||||
StockSpan stockSpan,
|
||||
Long totalNeeds,
|
||||
Function<List<EmoneyIndex>, Boolean> func,
|
||||
IndexInfo...indexInfos) {
|
||||
|
||||
this.tsCode = tsCode;
|
||||
this.beginPosition = beginPosition;
|
||||
this.stockSpan = stockSpan;
|
||||
setIndexInfos(indexInfos);
|
||||
this.totalNeeds = totalNeeds;
|
||||
this.func = func;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 EmoneyIndexCallable 恢复 Callable
|
||||
* @param request
|
||||
*/
|
||||
public EmoneyIndexCallable(EmoneyIndexCallable other) {
|
||||
this.tsCode = other.tsCode;
|
||||
this.beginPosition = other.beginPosition;
|
||||
this.stockSpan = other.stockSpan;
|
||||
this.indexInfos = other.indexInfos;
|
||||
this.indexNames = other.indexNames;
|
||||
this.totalNeeds = other.totalNeeds;
|
||||
this.func = other.func;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EmoneyCrawlerResult<EmoneyIndexCallable> call() throws Exception {
|
||||
|
||||
List<EmoneyIndex> emoneyIndexList = new ArrayList<>();
|
||||
while (totalNeeds > 0) {
|
||||
|
||||
// 组装请求
|
||||
/*
|
||||
* 2422 CANDLE_STICK_V3
|
||||
* 该种方式除返回指标和对应时间外,还会返回对应时间粒度的 K 线(蜡烛图),数据量会比 2921 大。
|
||||
* 使用 beginPosition, 如 20231230L 和 limitSize, 如 400 向前追溯
|
||||
*/
|
||||
CandleStickWithIndex_Request request = new CandleStickWithIndex_Request();
|
||||
CandleStick_Request candleRequest = new CandleStick_Request();
|
||||
candleRequest.setBeginPosition(beginPosition)
|
||||
.setDataPeriod(stockSpan.getEmoneyCode())
|
||||
.setExFlag(0)
|
||||
.setGoodsId(StockCodeUtils.tsCodeToGoodsId(tsCode))
|
||||
.setLastVolume(0)
|
||||
.setLimitSize(Math.min(totalNeeds, LIMIT_SIZE))
|
||||
.setReqFhsp(false)
|
||||
.setReqHisShares(false);
|
||||
request.candleRequest = candleRequest;
|
||||
|
||||
/*IndexInfo[] indexInfos = new IndexInfo[indexNames.length];
|
||||
|
||||
for (int i = 0; i < indexNames.length; i++) {
|
||||
IndexInfo indexInfo = new IndexInfo().setIndexName(indexNames[i]);
|
||||
indexInfos[i] = indexInfo;
|
||||
}*/
|
||||
request.indexRequest = indexInfos;
|
||||
|
||||
result = new EmoneyCrawlerResult<EmoneyIndexCallable>().setCallable(this).setSuccess(false);
|
||||
|
||||
|
||||
CandleStickNewWithIndexEx_Response response;
|
||||
try {
|
||||
// 时间戳作为 xRequestId
|
||||
response = EmoneyClient.post(
|
||||
request,
|
||||
CandleStickNewWithIndexEx_Response.class,
|
||||
2422,
|
||||
System.currentTimeMillis());
|
||||
}
|
||||
catch (EmoneyRequestException e) {
|
||||
return result.setSuccess(false).setException(e);
|
||||
}
|
||||
|
||||
log.info("Emoney index [{}] for {} called, totalNeeds left: {}, current received length/limitSize: {}/{}",
|
||||
indexNames, tsCode, totalNeeds, response.kLines.length, LIMIT_SIZE);
|
||||
|
||||
for (int i = 0; i < response.kLines.length; i++) {
|
||||
|
||||
CandleStick candleStick = response.kLines[i];
|
||||
|
||||
long dateTime = candleStick.getDatetime();
|
||||
LocalDateTime date = DateUtils.longDatetimeToLocalDateTime(dateTime);
|
||||
|
||||
for (IndexInfo indexInfo : indexInfos) {
|
||||
IndLine indLine = response.indexDatas.get(indexInfo.getIndexName());
|
||||
for (outputline lineValue : indLine.lineValue) {
|
||||
|
||||
EmoneyIndex emoneyIndex = new EmoneyIndex()
|
||||
.setIndexName(indexInfo.getIndexName())
|
||||
.setDate(date).setLineName(lineValue.getLineName())
|
||||
.setLineShape(lineValue.getLineShape())
|
||||
.setStockSpan(stockSpan)
|
||||
.setTsCode(tsCode)
|
||||
.setValue(lineValue.lineData[i]);
|
||||
|
||||
emoneyIndexList.add(emoneyIndex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Thread.sleep(2500);
|
||||
|
||||
// 判断数据是否结束了
|
||||
if (response.kLines.length < LIMIT_SIZE) {
|
||||
break;
|
||||
}
|
||||
|
||||
totalNeeds -= LIMIT_SIZE;
|
||||
|
||||
// 重设起始点为返回数据的最旧时间
|
||||
beginPosition = response.kLines[0].getDatetime();
|
||||
}
|
||||
Boolean b = true;
|
||||
if (!emoneyIndexList.isEmpty()) {
|
||||
try {
|
||||
b = func.apply(emoneyIndexList);
|
||||
}
|
||||
catch (Exception e) {
|
||||
return result.setSuccess(false).setException(e);
|
||||
}
|
||||
}
|
||||
|
||||
return result.setSuccess(b);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package quant.rich.emoney.entity.postgre;
|
||||
|
||||
import java.util.Date;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -36,7 +36,7 @@ public class EmoneyIndex {
|
||||
* 时间
|
||||
*/
|
||||
@TableField("trade_date")
|
||||
private Date date;
|
||||
private LocalDateTime date;
|
||||
/**
|
||||
* 指标值
|
||||
*/
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
package quant.rich.emoney.entity.sqlite;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
@@ -17,7 +8,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import quant.rich.emoney.mybatis.typehandler.CommaListTypeHandler;
|
||||
import quant.rich.emoney.enums.PlanType;
|
||||
import quant.rich.emoney.mybatis.typehandler.JsonStringTypeHandler;
|
||||
|
||||
/**
|
||||
@@ -27,6 +18,7 @@ import quant.rich.emoney.mybatis.typehandler.JsonStringTypeHandler;
|
||||
* <li>个股策略(/strategy, id=9400)的抓取
|
||||
* <li>选股策略的抓取
|
||||
* </ul>
|
||||
* 选股策略的抓取的筛选器自带股票过滤。其他抓取应该可以选股策略抓取的结果、tushare 的直接结果作为抓取的范围。
|
||||
* 未来可能存在更多的计划任务, 所以需要考虑如何设计才能更好地兼容后续添加的任务类型
|
||||
*/
|
||||
@Data
|
||||
@@ -38,7 +30,7 @@ public class Plan {
|
||||
* 键 ID
|
||||
*/
|
||||
@TableId(type = IdType.AUTO)
|
||||
private String planId;
|
||||
private Integer planId;
|
||||
|
||||
/**
|
||||
* 计划任务表达式
|
||||
@@ -49,23 +41,17 @@ public class Plan {
|
||||
* 计划名称
|
||||
*/
|
||||
private String planName;
|
||||
|
||||
/**
|
||||
* 指标代码
|
||||
*/
|
||||
private String indexCode;
|
||||
|
||||
/**
|
||||
* 需要抓取的指标周期
|
||||
* 计划类型
|
||||
*/
|
||||
@TableField(typeHandler = CommaListTypeHandler.class)
|
||||
private List<String> periods;
|
||||
private PlanType planType;
|
||||
|
||||
/**
|
||||
* 参数
|
||||
*/
|
||||
@TableField(typeHandler = JsonStringTypeHandler.class)
|
||||
private JsonNode params;
|
||||
private JsonNode detail;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
@@ -77,34 +63,5 @@ public class Plan {
|
||||
* 反之无论是否为交易日都执行
|
||||
*/
|
||||
private Boolean openDayCheck;
|
||||
|
||||
/**
|
||||
* 设置抓取周期并去重
|
||||
* @param periods
|
||||
* @return
|
||||
*/
|
||||
public Plan setPeriods(List<String> periods) {
|
||||
if (CollectionUtils.isEmpty(periods)) {
|
||||
this.periods = Collections.emptyList();
|
||||
return this;
|
||||
}
|
||||
Set<String> hashSet = new HashSet<>();
|
||||
periods.forEach(s -> {
|
||||
if (StringUtils.isNotBlank(s)) {
|
||||
hashSet.add(s.trim());
|
||||
}
|
||||
});
|
||||
this.periods = new ArrayList<>(hashSet);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取抓取周期。已去重
|
||||
* @return
|
||||
*/
|
||||
public List<String> getPeriods() {
|
||||
setPeriods(periods);
|
||||
return periods;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package quant.rich.emoney.enums;
|
||||
|
||||
/**
|
||||
* 计划任务类型枚举
|
||||
*/
|
||||
public enum PlanType {
|
||||
|
||||
/**
|
||||
* 单指标
|
||||
*/
|
||||
SINGLE_INDEX,
|
||||
/**
|
||||
* 多指标
|
||||
*/
|
||||
MULTI_INDEX,
|
||||
|
||||
STOCK_STRATEGY,
|
||||
STOCK_PICKING
|
||||
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package quant.rich.emoney.mapper.postgre;
|
||||
|
||||
import java.util.Collection;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.baomidou.dynamic.datasource.annotation.DS;
|
||||
@@ -13,4 +15,7 @@ import quant.rich.emoney.entity.postgre.EmoneyIndex;
|
||||
@DS("postgre")
|
||||
public interface EmoneyIndexMapper extends BaseMapper<EmoneyIndex> {
|
||||
|
||||
int insertOrUpdateBatch(
|
||||
@Param("list") Collection<EmoneyIndex> list,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package quant.rich.emoney.pojo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
@Data
|
||||
@Accessors(chain=true)
|
||||
public class MultiIndexPlanDetail {
|
||||
|
||||
/**
|
||||
* 指标
|
||||
*/
|
||||
List<MultiIndexPlanPart> indexes;
|
||||
|
||||
/**
|
||||
* 抓取的 K 线粒度
|
||||
*/
|
||||
List<Integer> periods;
|
||||
|
||||
/**
|
||||
* 设置抓取周期并去重
|
||||
* @param periods
|
||||
* @return
|
||||
*/
|
||||
public MultiIndexPlanDetail setPeriods(List<Integer> periods) {
|
||||
if (CollectionUtils.isEmpty(periods)) {
|
||||
this.periods = Collections.emptyList();
|
||||
return this;
|
||||
}
|
||||
Set<Integer> hashSet = new HashSet<>();
|
||||
periods.forEach(period -> {
|
||||
if (period != null) {
|
||||
hashSet.add(period);
|
||||
}
|
||||
});
|
||||
this.periods = new ArrayList<>(hashSet);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取抓取周期。已去重
|
||||
* @return
|
||||
*/
|
||||
public List<Integer> getPeriods() {
|
||||
setPeriods(periods);
|
||||
return periods;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Accessors(chain=true)
|
||||
public static class MultiIndexPlanPart {
|
||||
|
||||
/**
|
||||
* 单指标代码
|
||||
*/
|
||||
String indexCode;
|
||||
|
||||
/**
|
||||
* 单指标参数
|
||||
*/
|
||||
Map<String, String> params;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package quant.rich.emoney.pojo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
@Data
|
||||
@Accessors(chain=true)
|
||||
public class SingleIndexPlanDetail {
|
||||
|
||||
/**
|
||||
* 单指标代码
|
||||
*/
|
||||
String indexCode;
|
||||
|
||||
/**
|
||||
* 单指标参数
|
||||
*/
|
||||
Map<String, String> params;
|
||||
|
||||
/**
|
||||
* 抓取的 K 线粒度
|
||||
*/
|
||||
List<Integer> periods;
|
||||
|
||||
/**
|
||||
* 设置抓取周期并去重
|
||||
* @param periods
|
||||
* @return
|
||||
*/
|
||||
public SingleIndexPlanDetail setPeriods(List<Integer> periods) {
|
||||
if (CollectionUtils.isEmpty(periods)) {
|
||||
this.periods = Collections.emptyList();
|
||||
return this;
|
||||
}
|
||||
Set<Integer> hashSet = new HashSet<>();
|
||||
periods.forEach(period -> {
|
||||
if (period != null) {
|
||||
hashSet.add(period);
|
||||
}
|
||||
});
|
||||
this.periods = new ArrayList<>(hashSet);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取抓取周期。已去重
|
||||
* @return
|
||||
*/
|
||||
public List<Integer> getPeriods() {
|
||||
setPeriods(periods);
|
||||
return periods;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package quant.rich.emoney.service.postgre;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
|
||||
|
||||
import quant.rich.emoney.config.PostgreMybatisConfig;
|
||||
import quant.rich.emoney.entity.postgre.EmoneyIndex;
|
||||
import quant.rich.emoney.mapper.postgre.EmoneyIndexMapper;
|
||||
|
||||
public class EmoneyIndexService extends PostgreServiceImpl<EmoneyIndexMapper, EmoneyIndex> {
|
||||
|
||||
@Override
|
||||
@Transactional(transactionManager = PostgreMybatisConfig.POSTGRE_TRANSACTION_MANAGER, rollbackFor = Exception.class)
|
||||
public boolean saveOrUpdateBatch(Collection<EmoneyIndex> entityList, int batchSize) {
|
||||
return SqlHelper.retBool(baseMapper.insertOrUpdateBatch(entityList, batchSize));
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,16 @@ import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import feign.Headers;
|
||||
|
||||
/**
|
||||
* 对接 TushareDataService 的 Feign 客户端
|
||||
* <p>
|
||||
* 服务器上配置了 fail2ban 对默认 User-Agent
|
||||
* 拦截(Java/*),所以这里自定义 User-Agent
|
||||
*/
|
||||
@FeignClient(name="tushare-data-service-client", url="http://localhost:9999")
|
||||
@Headers("User-Agent: At17DataService/1.0")
|
||||
@FeignClient(name="tushare-data-service-client", url="https://tushare.database.at17.link")
|
||||
public interface TushareDataServiceClient {
|
||||
|
||||
@GetMapping("/api/v1/common/stockInfo/list")
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package quant.rich.emoney.util;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
public class ArithmeticCaptchaGen {
|
||||
|
||||
private static final SecureRandom RND = new SecureRandom();
|
||||
private static final char[] OPS = new char[]{'+', '-', '×', '÷'};
|
||||
|
||||
public static class Captcha {
|
||||
public final int a;
|
||||
public final int b;
|
||||
public final char op;
|
||||
|
||||
public Captcha(int a, int b, char op) {
|
||||
this.a = a;
|
||||
this.b = b;
|
||||
this.op = op;
|
||||
}
|
||||
|
||||
public String expr() {
|
||||
return a + " " + op + " " + b;
|
||||
}
|
||||
|
||||
public int answer() {
|
||||
return switch (op) {
|
||||
case '+' -> a + b;
|
||||
case '-' -> a - b;
|
||||
case '×' -> a * b;
|
||||
case '÷' -> a / b; // 保证整除
|
||||
default -> throw new IllegalStateException("Unexpected op: " + op);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return expr() + " = ?";
|
||||
}
|
||||
}
|
||||
|
||||
/** 对外:随机生成一题 */
|
||||
public static Captcha generate() {
|
||||
char op = OPS[RND.nextInt(OPS.length)];
|
||||
return generate(op);
|
||||
}
|
||||
|
||||
/** 对外:指定运算符生成 */
|
||||
public static Captcha generate(char op) {
|
||||
return switch (op) {
|
||||
case '+' -> genAddCarryAllowedButIfCarryUnitsZero();
|
||||
case '-' -> genSubNoBorrowNonNegative();
|
||||
case '×' -> genMulNoCarryOneDigit();
|
||||
case '÷' -> genDivExact();
|
||||
default -> throw new IllegalArgumentException("Unsupported op: " + op);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- +:允许进位;若发生进位,则结果个位必须为 0;且和 <= 100 ----------------
|
||||
private static Captcha genAddCarryAllowedButIfCarryUnitsZero() {
|
||||
// 仍保留 100 的特殊题(不是必须,但能保证覆盖 100)
|
||||
if (RND.nextInt(20) == 0) { // 5% 概率出 100
|
||||
if (RND.nextBoolean()) return new Captcha(100, 0, '+');
|
||||
return new Captcha(0, 100, '+');
|
||||
}
|
||||
|
||||
while (true) {
|
||||
int a = RND.nextInt(101); // 0..100
|
||||
int b = RND.nextInt(101); // 0..100
|
||||
int sum = a + b;
|
||||
if (sum > 100) continue;
|
||||
|
||||
if (carryHappened(a, b)) {
|
||||
// 发生进位:个位必须为 0
|
||||
if (sum % 10 == 0) return new Captcha(a, b, '+');
|
||||
} else {
|
||||
// 未发生进位:不限制个位
|
||||
return new Captcha(a, b, '+');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断十进制逐位相加是否发生过进位(任意一位) */
|
||||
private static boolean carryHappened(int a, int b) {
|
||||
int carry = 0;
|
||||
while (a > 0 || b > 0) {
|
||||
int da = a % 10;
|
||||
int db = b % 10;
|
||||
int s = da + db + carry;
|
||||
if (s >= 10) return true;
|
||||
carry = 0; // 因为 s<10 时 carry 必为 0
|
||||
a /= 10;
|
||||
b /= 10;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------- -:无退位,差 >= 0 ----------------
|
||||
private static Captcha genSubNoBorrowNonNegative() {
|
||||
while (true) {
|
||||
int a = RND.nextInt(100); // 0..99
|
||||
int b = RND.nextInt(100); // 0..99
|
||||
|
||||
int aT = a / 10, aU = a % 10;
|
||||
int bT = b / 10, bU = b % 10;
|
||||
|
||||
// 无退位:每一位都要 a>=b;且整体差>=0
|
||||
if (aU >= bU && aT >= bT) {
|
||||
return new Captcha(a, b, '-'); // 此时 a>=b 自然成立
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- *:无进位;一元为 0..9;另一元每位*d < 10 ----------------
|
||||
private static Captcha genMulNoCarryOneDigit() {
|
||||
while (true) {
|
||||
int d = RND.nextInt(10); // 0..9 其中一个因子
|
||||
int x = RND.nextInt(100); // 另一元 0..99(你可自行放大范围)
|
||||
|
||||
// 如果 d=0,任何 x 都满足
|
||||
if (d == 0) return orderMulOperands(d, x);
|
||||
|
||||
// 对 x 的每一位要求:digit*d < 10 才不会在该位产生进位
|
||||
if (allDigitsMulLessThan10(x, d)) {
|
||||
return orderMulOperands(d, x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Captcha orderMulOperands(int d, int x) {
|
||||
// 随机决定把 0..9 放左边还是右边
|
||||
if (RND.nextBoolean()) return new Captcha(d, x, '×');
|
||||
return new Captcha(x, d, '×');
|
||||
}
|
||||
|
||||
private static boolean allDigitsMulLessThan10(int x, int d) {
|
||||
// x 是非负
|
||||
if (x == 0) return true;
|
||||
int t = x;
|
||||
while (t > 0) {
|
||||
int digit = t % 10;
|
||||
if (digit * d >= 10) return false;
|
||||
t /= 10;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------- /:必须整除 ----------------
|
||||
private static Captcha genDivExact() {
|
||||
// 控制题目大小: dividend <= 100
|
||||
while (true) {
|
||||
int b = 1 + RND.nextInt(10); // 除数 1..10
|
||||
int q = RND.nextInt(21); // 商 0..20
|
||||
int a = b * q; // 被除数
|
||||
if (a <= 100) return new Captcha(a, b, '÷');
|
||||
}
|
||||
}
|
||||
|
||||
// demo
|
||||
public static void main(String[] args) {
|
||||
for (int i = 0; i < 100; i++) {
|
||||
Captcha c = generate();
|
||||
System.out.println(c.expr() + " = " + c.answer());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package quant.rich.emoney.util;
|
||||
import java.util.*;
|
||||
|
||||
public class ChunkRandomIter {
|
||||
|
||||
final static Random RANDOM = new Random();
|
||||
|
||||
public static <T> List<T> splitShuffleAndFlatten(List<T> list, int n) {
|
||||
int size = list.size();
|
||||
if (n <= 0) throw new IllegalArgumentException("n must be > 0");
|
||||
n = Math.min(n, size == 0 ? 1 : size);
|
||||
|
||||
List<T> parts = new ArrayList<>(n);
|
||||
|
||||
int base = size / n; // 每份至少 base 个
|
||||
int extra = size % n; // 前 extra 份多一个
|
||||
|
||||
int idx = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
int partSize = base + (i < extra ? 1 : 0);
|
||||
List<T> part = new ArrayList<>(list.subList(idx, idx + partSize));
|
||||
Collections.shuffle(part, RANDOM);
|
||||
parts.addAll(part);
|
||||
idx += partSize;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package quant.rich.emoney.util;
|
||||
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -55,4 +56,40 @@ public final class DateUtils {
|
||||
LocalDate date = LocalDate.parse(value, formatter);
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数字类型日期转换成相应的 LocalDateTime
|
||||
* <p>
|
||||
* <li> 2309191130 → 2023-09-19 11:30:00
|
||||
* <li> 230919 → 2023-09-19
|
||||
*/
|
||||
public static LocalDateTime longDatetimeToLocalDateTime(long dateTime) {
|
||||
if (dateTime <= 0) {
|
||||
throw new IllegalArgumentException("dateTime must be positive, got: " + dateTime);
|
||||
}
|
||||
|
||||
try {
|
||||
if (dateTime > 99_999_999L) {
|
||||
// yyMMddHHmm 例如:2309191130 -> 2023-09-19 11:30
|
||||
int yy = (int) (dateTime / 100_000_000L); // 23
|
||||
int year = 2000 + yy; // 2023
|
||||
int month = (int) ((dateTime % 100_000_000L) / 1_000_000L); // 09
|
||||
int day = (int) ((dateTime % 1_000_000L) / 10_000L); // 19
|
||||
int hour = (int) ((dateTime % 10_000L) / 100L); // 11
|
||||
int minute = (int) (dateTime % 100L); // 30
|
||||
|
||||
return LocalDateTime.of(year, month, day, hour, minute, 0);
|
||||
} else {
|
||||
// yyyyMMdd 例如:20230919 -> 2023-09-19 00:00
|
||||
int year = (int) (dateTime / 10_000L); // 2023
|
||||
int month = (int) ((dateTime % 10_000L) / 100L); // 09
|
||||
int day = (int) (dateTime % 100L); // 19
|
||||
|
||||
return LocalDateTime.of(year, month, day, 0, 0, 0);
|
||||
}
|
||||
} catch (DateTimeException ex) {
|
||||
// 月/日/时/分越界会进这里(比如 month=13)
|
||||
throw new IllegalArgumentException("Invalid dateTime value: " + dateTime, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user