getAllOpenDateSence(StockMarket exchange, @Nullable Temporal sence) {
+ return baseMapper.getAllOpenDateSence(exchange, sence);
+ }
+
+ /**
+ * 判断指定日是否为指定交易所的交易日
+ * @param date 会截断时分秒,只取年月日
+ * @param exchange 目前只支持 SSE(深交所)和 SHSE(上交所)
+ * @return
+ * @see #isOpen(Temporal)
+ */
+ public boolean isOpen(@NonNull Temporal date, @Nullable StockMarket exchange) {
+ return baseMapper.isOpen(date, exchange);
+ }
+
+ /**
+ * 判断指定日是否为任意交易所的交易日
+ * @param date 会截断时分秒,只取年月日
+ * @return
+ * @see #isOpen(Temporal, StockMarket)
+ */
+ public boolean isOpen(@NonNull Temporal date) {
+ return baseMapper.isOpen(date, null);
+ }
+
+ /**
+ * 获取数据库内指定证交所的最早一个交易日历
+ * 仅为日期,并未指定是否是开市日
+ * @param stockMarket 指定证交所,若为 {@code null} 则取最早一条(市场不确定)
+ * @return
+ */
+ public StockCalendar getGreatest(@Nullable StockMarket stockMarket) {
+ return baseMapper.getGreatest(stockMarket);
+ }
+
+ /**
+ * 获取数据库内指定证交所的最早一个交易日历
+ *
仅为日期,并未指定是否是开市日。取最早一条(市场不确定)
+ * @see #getGreatest(StockMarket)
+ * @return
+ */
+ public StockCalendar getGreatest() {
+ return getGreatest(null);
+ }
+
+ /**
+ * 获取数据库内指定证交所的最早一个交易日历的日期
+ *
仅为日期,并未指定是否是开市日。取最早一条(市场不确定)
+ * @see #getGreatest
+ * @return {@code LocalDate} 或 {@code null}
+ */
+ public LocalDate getGreatestLocalDate() {
+ return getGreatestLocalDate(null);
+ }
+
+ /**
+ * 获取数据库内指定证交所的最早一个交易日历的日期
+ *
仅为日期,并未指定是否是开市日
+ * @see #getGreatest
+ * @param stockMarket 指定证交所,若为 {@code null} 则取最早一条(市场不确定)
+ * @return {@code LocalDate} 或 {@code null}
+ */
+ public LocalDate getGreatestLocalDate(@Nullable StockMarket stockMarket) {
+ StockCalendar stockCalendar = getGreatest(stockMarket);
+ if (stockCalendar != null) {
+ return stockCalendar.getDate().atStartOfDay().toLocalDate();
+ }
+ return null;
+ }
+
+ /**
+ * 获取数据库内指定证交所的最早一个交易日历的日期时间
+ *
仅为日期,并未指定是否是开市日。取最早一条(市场不确定)
+ * @see #getGreatest
+ * @return {@code LocalDate} 或 {@code null}
+ */
+ public LocalDateTime getGreatestLocalDateTime() {
+ return getGreatestLocalDateTime(null);
+ }
+
+ /**
+ * 获取数据库内指定证交所的最早一个交易日历的日期时间
+ *
仅为日期,并未指定是否是开市日
+ * @see #getGreatest
+ * @param stockMarket 指定证交所,若为 {@code null} 则取最早一条(市场不确定)
+ * @return {@code LocalDateTime} 或 {@code null}
+ */
+ public LocalDateTime getGreatestLocalDateTime(@Nullable StockMarket stockMarket) {
+ StockCalendar stockCalendar = getGreatest(stockMarket);
+ if (stockCalendar != null) {
+ return stockCalendar.getDate().atStartOfDay();
+ }
+ return null;
+ }
+
+
+ /**
+ * 获取数据库内指定证交所的最新一个交易日历
+ *
仅为日期,并未指定是否是开市日
+ * @param stockMarket 指定证交所,若为 {@code null} 则取最新一条(市场不确定)
+ * @return
+ */
+ public StockCalendar getLatest(@Nullable StockMarket stockMarket) {
+ return baseMapper.getLatest(stockMarket);
+ }
+
+ /**
+ * 获取数据库内指定证交所的最近一个交易日历
+ *
仅为日期,并未指定是否是开市日。取最近一条(市场不确定)
+ * @see #getLatest(StockMarket)
+ * @return
+ */
+ public StockCalendar getLatest() {
+ return getLatest(null);
+ }
+
+
+ /**
+ * 获取数据库内指定证交所的最近一个交易日历的日期
+ *
仅为日期,并未指定是否是开市日
+ * @see #getGreatest
+ * @param stockMarket 指定证交所,若为 {@code null} 则取最近一条(市场不确定)
+ * @return {@code LocalDate} 或 {@code null}
+ */
+ public LocalDate getLatestLocalDate(@Nullable StockMarket stockMarket) {
+ StockCalendar stockCalendar = getLatest(stockMarket);
+ if (stockCalendar != null) {
+ return stockCalendar.getDate().atStartOfDay().toLocalDate();
+ }
+ return null;
+ }
+
+ /**
+ * 获取数据库内指定证交所的最近一个交易日历的日期
+ *
仅为日期,并未指定是否是开市日。取最近一条(市场不确定)
+ * @see #getGreatest
+ * @return {@code LocalDate} 或 {@code null}
+ */
+ public LocalDate getLatestLocalDate() {
+ return getLatestLocalDate(null);
+ }
+
+ /**
+ * 获取数据库内指定证交所的最近一个交易日历的日期时间
+ *
仅为日期时间,并未指定是否是开市日
+ * @see #getGreatest
+ * @param stockMarket 指定证交所,若为 {@code null} 则取最近一条(市场不确定)
+ * @return {@code LocalDateTime} 或 {@code null}
+ */
+ public LocalDateTime getLatestLocalDateTime(@Nullable StockMarket stockMarket) {
+ StockCalendar stockCalendar = getLatest(stockMarket);
+ if (stockCalendar != null) {
+ return stockCalendar.getDate().atStartOfDay();
+ }
+ return null;
+ }
+
+
+ /**
+ * 获取数据库内指定证交所的最近一个交易日历的日期时间
+ *
仅为日期时间,并未指定是否是开市日。取最近一条(市场不确定)
+ * @see #getGreatest
+ * @return {@code LocalDateTime} 或 {@code null}
+ */
+ public LocalDateTime getLatestLocalDateTime() {
+ return getLatestLocalDateTime(null);
+ }
+
+ /**
+ * 查询今日是否开市,任意市场开市都返回 true
+ * @see #todayIsOpen(StockMarket)
+ * @return
+ */
+ public boolean todayIsOpen() {
+ return baseMapper.isOpen(LocalDateTime.now(), null);
+ }
+
+
+ /**
+ * 查询今日是否是开市日
+ * @param stockMarket 指定市场,为 null 则任意市场开市都返回 true
+ * @return
+ */
+ public boolean todayIsOpen(StockMarket stockMarket) {
+ return baseMapper.isOpen(LocalDateTime.now(), stockMarket);
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/service/StockDailyBasicService.java b/src/main/java/link/at17/mid/tushare/data/service/StockDailyBasicService.java
new file mode 100644
index 0000000..0646d24
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/service/StockDailyBasicService.java
@@ -0,0 +1,114 @@
+package link.at17.mid.tushare.data.service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.function.Function;
+
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
+
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.dao.StockDailyBasicDao;
+import link.at17.mid.tushare.data.crawler.QueryWay;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawler;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawlerResult;
+import link.at17.mid.tushare.data.crawler.tushare.TushareRequestBody;
+import link.at17.mid.tushare.data.models.StockValueEx;
+import link.at17.mid.tushare.data.models.interfaces.ITsStockInfo;
+import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
+import link.at17.mid.tushare.data.validator.AllowedEnum;
+import link.at17.mid.tushare.service.BaseServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Slf4j
+public class StockDailyBasicService extends BaseServiceImpl implements ITsTradeDate {
+
+ @Autowired
+ StockInfoService stockInfoService;
+
+ @Autowired
+ TushareCrawler tushareCrawler;
+
+ @Autowired
+ RedissonClient redis;
+
+ /**
+ * 更新由 Tushare 获取的数据
+ * @param list
+ * @return
+ */
+ public boolean insertOrUpdateList(List list) {
+ return SqlHelper.retBool(baseMapper.insertOrUpdateList(list));
+ }
+
+ @Override
+ public LocalDateTime getLatestTradeDate(ITsStockInfo stockInfo) {
+ return baseMapper.getLatestTradeDate(stockInfo);
+ }
+
+ @Override
+ public List getAllTradeDates(ITsStockInfo stockInfo) {
+ return baseMapper.getAllTradeDates(stockInfo);
+ }
+
+ @Override
+ public List getAllMissingDates(ITsStockInfo stockInfo) {
+ return baseMapper.getAllMissingDates(stockInfo);
+ }
+
+ /**
+ * 获取个股每日指标
+ *
+ * 接口:daily_basic
+ * 更新时间:交易日每日15点~17点之间
+ * 描述:获取全部股票每日重要的基本面指标,可用于选股分析、报表展示等。
+ * 积分:用户需要至少600积分才可以调取,具体请参阅
+ * 积分获取办法
+ *
+ */
+ @UpdateMethod(name="每日基本指标", order=3)
+ public boolean updateData(
+ @AllowedEnum({"ByDateUpdate", "ByStockCrossCheck"})
+ QueryWay queryWay) {
+ TushareRequestBody baseRequest = new TushareRequestBody("daily_basic");
+ baseRequest.addFields("ts_code,trade_date,close,turnover_rate,turnover_rate_f,volume_ratio,pe,pe_ttm,pb,ps,ps_ttm,dv_ratio,dv_ttm,total_share,float_share,free_share,total_mv,circ_mv");
+ Function, Boolean> function = (t) -> {
+ insertOrUpdateList(t);
+ return true;
+ };
+ List> executeResult;
+ if (queryWay.compareTo(QueryWay.ByStock) >= 0) {
+ executeResult = tushareCrawler.rollingQueryByStock(baseRequest, stockInfoService.list(), this, function, 5000L, queryWay, null);
+ }
+ else {
+ executeResult = tushareCrawler.rollingQueryByDate(baseRequest, this, function, queryWay, null);
+ }
+ log.info("每日指标数据更新完成");
+ for (Future f : executeResult) {
+ try {
+ TushareCrawlerResult result = f.get();
+ JSONObject request = f.get().getRequest();
+ if (result.isFatal()) {
+ log.error("每日指标数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
+ }
+ else if (!result.isSuccess()) {
+ log.warn("每日指标数据未获取:{}", request.toJSONString());
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ log.error("检查每日指标数据执行结果时发生错误", e);
+ }
+ }
+ if (baseRequest.hasFingerprint()) {
+ redis.getBucket(baseRequest.getFingerprint()).delete();
+ }
+ return true;
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/service/StockDailyService.java b/src/main/java/link/at17/mid/tushare/data/service/StockDailyService.java
new file mode 100644
index 0000000..4718879
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/service/StockDailyService.java
@@ -0,0 +1,177 @@
+package link.at17.mid.tushare.data.service;
+
+import java.time.LocalDateTime;
+import java.time.temporal.Temporal;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.function.Function;
+
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
+
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.dao.StockDailyDao;
+import link.at17.mid.tushare.data.crawler.QueryWay;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawler;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawlerResult;
+import link.at17.mid.tushare.data.crawler.tushare.TushareRequestBody;
+import link.at17.mid.tushare.data.models.StockValue;
+import link.at17.mid.tushare.data.models.StockValueEx;
+import link.at17.mid.tushare.data.models.interfaces.ITsStockInfo;
+import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
+import link.at17.mid.tushare.data.validator.AllowedEnum;
+import link.at17.mid.tushare.service.BaseServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Slf4j
+public class StockDailyService extends BaseServiceImpl implements ITsTradeDate {
+
+ @Autowired
+ StockInfoService stockInfoService;
+
+ @Autowired
+ TushareCrawler tushareCrawler;
+
+ @Autowired
+ RedissonClient redis;
+
+ /**
+ * 更新由 Tushare 获取的数据
+ * @param list
+ * @return
+ */
+ public boolean insertOrUpdateList(List list) {
+ return SqlHelper.retBool(baseMapper.insertOrUpdateList(list));
+ }
+
+ /**
+ * 获取除权日线数据 + 基本行情数据
+ * @param tsCode Tushare 股票代码,不允许为空
+ * @param endDate 结束日期(包含),留空则为最新一个交易日
+ * @param before 多少个交易日以前,留空则查询上市至 endDate 以来所有数据
+ * @return
+ */
+ public List getExDailyBefore(String tsCode, Temporal endDate, Long before) {
+ return baseMapper.getExDailyBefore(tsCode, endDate, before);
+ }
+ /**
+ * 获取前复权日线数据
+ * @param tsCode Tushare 股票代码,不允许为空
+ * @param startDate 开始日期,留空则为股票上市日起
+ * @param endDate 结束日期,null 则为最新一个交易日
+ * @return
+ */
+ public List getQfqDaily(String tsCode, Temporal startDate, Temporal endDate) {
+ return baseMapper.getQfqDaily(tsCode, startDate, endDate);
+ }
+ /**
+ * 获取前复权日线数据
+ * @param tsCode Tushare 股票代码,不允许为空
+ * @param endDate 结束日期,留空则为最新一个交易日
+ * @param before 多少个交易日以前,null 则查询上市以来所有数据
+ * @return
+ */
+ public List getQfqDailyBefore(String tsCode, Temporal endDate, Long before) {
+ return baseMapper.getQfqDailyBefore(tsCode, endDate, before);
+ }
+ /**
+ * 获取前复权日线数据 + 基本行情数据
+ * @param tsCode Tushare 股票代码,不允许为空
+ * @param startDate 开始日期(包含),留空则为上市第一日
+ * @param after 多少个交易日以前,null 则查询 startDate 以来所有数据
+ * @return
+ */
+ public List getExQfqDailyAfter(String tsCode, Temporal startDate, Long after) {
+ return baseMapper.getExQfqDailyAfter(tsCode, startDate, after);
+ }
+ /**
+ * 获取前复权日线数据 + 基本行情数据
+ * @param tsCode Tushare 股票代码,不允许为空
+ * @param endDate 结束日期,留空则为最新一个交易日
+ * @param before 多少个交易日以前,null 则查询上市以来所有数据
+ * @return
+ */
+ public List getExQfqDailyBefore(String tsCode, Temporal endDate, Long before) {
+ return baseMapper.getExQfqDailyBefore(tsCode, endDate, before);
+ }
+ /**
+ * 获取前复权日线数据
+ * @param tsCode Tushare 股票代码,不允许为空
+ * @param startDate 开始日期(包含),留空则为上市第一日
+ * @param after 多少个交易日以前,null 则查询 startDate 以来所有数据
+ * @return
+ */
+ public List getQfqDailyAfter(String tsCode, Temporal startDate, Long after) {
+ return baseMapper.getQfqDailyAfter(tsCode, startDate, after);
+ }
+
+ @Override
+ public LocalDateTime getLatestTradeDate(ITsStockInfo stockInfo) {
+ return baseMapper.getLatestTradeDate(stockInfo);
+ }
+
+ @Override
+ public List getAllTradeDates(ITsStockInfo stockInfo) {
+ return baseMapper.getAllTradeDates(stockInfo);
+ }
+
+ @Override
+ public List getAllMissingDates(ITsStockInfo stockInfo) {
+ return baseMapper.getAllMissingDates(stockInfo);
+ }
+
+ /**
+ * 更新日K数据
+ * 基础积分每分钟内最多调取500次,每次5000条数据,相当于23年历史,用户获得超过5000积分正常调取无频次限制。
+ *
+ * 请先更新交易日历和股票列表后再调用该方法,否则可能造成股票日 K 数据缺失
+ * @param queryWay 滚动查询方式
+ * @see TushareCrawler#rollingQueryByDate
+ * @see TushareCrawler#rollingQueryByStock
+ */
+
+ @UpdateMethod(name="日线数据", order=2)
+ public boolean updateData(
+ @AllowedEnum({"ByDateUpdate", "ByStockCrossCheck", "ByDateAll"})
+ QueryWay queryWay) {
+ TushareRequestBody baseRequest = new TushareRequestBody("daily")
+ .addFields("ts_code,trade_date,open,high,low,close,pre_close,change,pct_chg,vol,amount");
+ Function, Boolean> function = (t) -> {
+ insertOrUpdateList(t);
+ return true;
+ };
+ List> executeResult;
+ if (queryWay.compareTo(QueryWay.ByStock) >= 0) {
+ executeResult = tushareCrawler.rollingQueryByStock(baseRequest, stockInfoService.list(), this, function, 5000L, queryWay, null);
+ }
+ else {
+ executeResult = tushareCrawler.rollingQueryByDate(baseRequest, this, function, queryWay, null);
+ }
+ log.info("日 K 数据更新完成");
+ for (Future f : executeResult) {
+ try {
+ TushareCrawlerResult result = f.get();
+ JSONObject request = f.get().getRequest();
+ if (result.isFatal()) {
+ log.error("日 K 数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
+ }
+ else if (!result.isSuccess()) {
+ log.warn("日 K 数据未获取:{}", request.toJSONString());
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ log.error("检查日 K 数据执行结果时发生错误", e);
+ }
+ }
+ if (baseRequest.hasFingerprint()) {
+ redis.getBucket(baseRequest.getFingerprint()).delete();
+ }
+ return true;
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/service/StockHolderService.java b/src/main/java/link/at17/mid/tushare/data/service/StockHolderService.java
new file mode 100644
index 0000000..da19370
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/service/StockHolderService.java
@@ -0,0 +1,138 @@
+package link.at17.mid.tushare.data.service;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.function.Function;
+
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
+
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.dao.StockHolderDao;
+import link.at17.mid.tushare.data.crawler.QueryWay;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawler;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawlerResult;
+import link.at17.mid.tushare.data.crawler.tushare.TushareRequestBody;
+import link.at17.mid.tushare.data.models.StockHolder;
+import link.at17.mid.tushare.data.models.interfaces.ITsStockInfo;
+import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
+import link.at17.mid.tushare.enums.StockHolderType;
+import link.at17.mid.tushare.service.BaseServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Slf4j
+public class StockHolderService extends BaseServiceImpl implements ITsTradeDate {
+
+ @Autowired
+ RedissonClient redis;
+
+ @Autowired
+ TushareCrawler tushareCrawler;
+
+ @Autowired
+ StockInfoService stockInfoService;
+
+
+ /**
+ * 前十大股东/前十大流通股东
+ *
+ * 接口:top10_holders/top10_floatholders
+ * 更新时间:不定时,报告日
+ * 描述:获取上市公司前十大股东数据,包括持有数量和比例等信息/获取上市公司前十大流通股东数据,不包括比例信息
+ *
+ * 文档说单次 100 条限制,但实际测试有单次 5000 条
+ * 请先更新交易日历和股票列表后再调用该方法
+ * 如果是 A+H 或者 A+B 股,可能存在同股票存在多个同名股东,且持股数据不一致。需要仔细研究如何清洗和使用
+ */
+ // TODO: A+H 或者 A+B 股是否还需要重命名?
+ @UpdateMethod(name="十大股东数据", order=10)
+ public boolean updateData() {
+
+ StockHolderType[] holderTypes = new StockHolderType[] {
+ StockHolderType.TOP10,
+ StockHolderType.TOP10Float
+ };
+
+ for (StockHolderType holderType : holderTypes) {
+
+ boolean isFloat = holderType.getIsFloat() == 1;
+ TushareRequestBody baseRequest = new TushareRequestBody(isFloat ? "top10_floatholders" : "top10_holders");
+ Function, Boolean> function = (t) -> {
+ Map map = new HashMap<>();
+ for (JSONObject jo : t) {
+ String holderName = jo.getString("holder_name");
+ String[] temp = {jo.getString("ts_code"), jo.getString("end_date"), holderName, holderType.name()};
+ String key = String.join("_", temp);
+ Integer holderOffset = 1;
+ while (map.containsKey(key)) {
+ log.warn("存在重复的 key {},开始重命名", key);
+ temp[2] = holderName + '^' + holderOffset++;
+ jo.put("holder_name", temp[2]);
+ key = String.join("_", temp);
+ }
+ map.put(key, jo);
+ }
+ insertOrUpdateList(t, holderType);
+ return true;
+ };
+ List> executeResult;
+ executeResult = tushareCrawler.rollingQueryByStock(baseRequest, stockInfoService.list(), baseMapper, function, 5000L, QueryWay.ByStock, null);
+ String banner = "十大" + (isFloat ? "流通股东" : "股东");
+ log.info("{}数据更新完成", banner);
+ for (Future f : executeResult) {
+ try {
+ TushareCrawlerResult result = f.get();
+ JSONObject request = f.get().getRequest();
+ if (result.isFatal()) {
+ log.error("{}数据未获取,发生致命错误,将不会重试:{}", banner, request.toJSONString());
+ }
+ else if (!result.isSuccess()) {
+ log.info("{}数据未获取:{}", banner, request.toJSONString());
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ log.error("检查" + banner + "数据执行结果时发生错误", e);
+ }
+ }
+ if (baseRequest.hasFingerprint()) {
+ redis.getBucket(baseRequest.getFingerprint()).delete();
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * 插入从 Tushare 抓取的内容
+ *
+ * @param list
+ * @return
+ */
+ public boolean insertOrUpdateList(List list, StockHolderType holderType) {
+ return SqlHelper.retBool(baseMapper.insertOrUpdateList(list, holderType));
+ }
+
+ @Override
+ public LocalDateTime getLatestTradeDate(ITsStockInfo stockInfo) {
+ return baseMapper.getLatestTradeDate(stockInfo);
+ }
+
+ @Override
+ public List getAllTradeDates(ITsStockInfo stockInfo) {
+ return baseMapper.getAllTradeDates(stockInfo);
+ }
+
+ @Override
+ public List getAllMissingDates(ITsStockInfo stockInfo) {
+ return baseMapper.getAllMissingDates(stockInfo);
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/service/StockInfoService.java b/src/main/java/link/at17/mid/tushare/data/service/StockInfoService.java
new file mode 100644
index 0000000..66eced8
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/service/StockInfoService.java
@@ -0,0 +1,105 @@
+package link.at17.mid.tushare.data.service;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.springframework.stereotype.Service;
+
+import com.alibaba.fastjson2.JSONException;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.github.yulichang.query.MPJLambdaQueryWrapper;
+
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.dao.StockInfoDao;
+import link.at17.mid.tushare.data.crawler.tushare.TushareClient;
+import link.at17.mid.tushare.data.crawler.tushare.TushareRequestBody;
+import link.at17.mid.tushare.data.models.StockInfo;
+import link.at17.mid.tushare.enums.ListStatus;
+import link.at17.mid.tushare.service.BaseServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Slf4j
+public class StockInfoService extends BaseServiceImpl {
+
+
+ @Override
+ public List list() {
+ return baseMapper.selectList(null);
+ }
+
+ /**
+ * 用这个方法的话,传入的 Wrapper 需要设置别名 i
+ *
+ * 如果用 QueryWrapper:
+ *
+ * {@code ew.eq("i.ts_code", "000001.SZ")}
+ *
+ * 如果用 MPJLambdaQueryWrapper:
+ *
+ * {@code ew.setAlias("i").eq(StockInfo::getTsCode, "000001.SZ")}
+ */
+ @Override
+ public List list(Wrapper ew) {
+ return baseMapper.selectList(ew);
+ }
+
+ /**
+ * 用于插入从 Tushare 获取的内容
+ * @param list
+ */
+ public void insertOrUpdateList(List list) {
+ baseMapper.insertOrUpdateList(list);
+ }
+
+ /**
+ * 根据上市状态列举对应所有股票
+ * @param listStatus
+ * @return
+ */
+ public List listByListStatus(ListStatus listStatus) {
+ MPJLambdaQueryWrapper ew = new MPJLambdaQueryWrapper<>();
+ return baseMapper.selectList(ew.setAlias("i").eq(StockInfo::getListStatus, listStatus));
+ }
+
+ /**
+ * 股票列表
+ * 更新股票列表
+ * 接口:stock_basic,可以通过数据工具调试和查看数据
+ * 描述:获取基础信息数据,包括股票代码、名称、上市日期、退市日期等
+ * 积分:2000积分起
+ * 包括上市、退市和暂停上市,无权限(积分不足)状态下每小时最多访问该接口 1 次
+ *
+ *
+ * @param queryWay 查询方法,本例中为 null,因为更新股票列表不需要指定 QueryWay
+ */
+
+ @UpdateMethod(name="股票列表", order=1)
+ public boolean updateData() {
+ try {
+ List stockInfos = new ArrayList<>();
+ // Tushare 经常改请求参数的规则,2024/11/13 更新:默认不提供 list_status 时,默认值是 L
+ TushareRequestBody requestBody = new TushareRequestBody("stock_basic")
+ .addFields("ts_code,name,area,industry,market,list_status,list_date,delist_date");
+
+ requestBody.addParam("list_status", "L");
+ stockInfos.addAll(TushareClient.queryList(requestBody));
+
+ requestBody.addParam("list_status", "D");
+ stockInfos.addAll(TushareClient.queryList(requestBody));
+
+ requestBody.addParam("list_status", "P");
+ stockInfos.addAll(TushareClient.queryList(requestBody));
+
+ insertOrUpdateList(stockInfos);
+
+ log.info("更新股票列表完成");
+ return true;
+ } catch (JSONException | IOException e) {
+ log.error("更新股票列表时发生错误", e);
+ return false;
+ }
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/service/StockLimitService.java b/src/main/java/link/at17/mid/tushare/data/service/StockLimitService.java
new file mode 100644
index 0000000..3d1fbaa
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/service/StockLimitService.java
@@ -0,0 +1,115 @@
+package link.at17.mid.tushare.data.service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.function.Function;
+
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
+
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.dao.StockLimitDao;
+import link.at17.mid.tushare.data.crawler.QueryWay;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawler;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawlerResult;
+import link.at17.mid.tushare.data.crawler.tushare.TushareRequestBody;
+import link.at17.mid.tushare.data.models.StockLimit;
+import link.at17.mid.tushare.data.models.interfaces.ITsStockInfo;
+import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
+import link.at17.mid.tushare.data.validator.AllowedEnum;
+import link.at17.mid.tushare.service.BaseServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Slf4j
+public class StockLimitService extends BaseServiceImpl implements ITsTradeDate {
+
+ @Autowired
+ RedissonClient redis;
+
+ @Autowired
+ TushareCrawler tushareCrawler;
+
+ @Autowired
+ StockInfoService stockInfoService;
+
+
+ /**
+ * Tushare 涨跌停列表
+ *
+ * - 接口:limit_list_d
+ * - 描述:获取沪深A股每日涨跌停、炸板数据情况,数据从2020年开始
+ * - 限量:单次最大可以获取500条数据,可通过日期或者股票循环提取
+ * - 积分:120积分可查看数据,5000积分每分钟可以请求200次,8000积分以上每分钟500次,具体请参阅积分获取办法
+ *
+ */
+ @UpdateMethod(name="涨跌停数据", order=5)
+ public boolean updateData(
+ @AllowedEnum({"ByDateUpdate", "ByStockCrossCheck", "ByDateAll"})
+ QueryWay queryWay) {
+
+ TushareRequestBody baseRequest = new TushareRequestBody("limit_list_d");
+ Function, Boolean> function = (t) -> {
+ insertOrUpdateList(t);
+ return true;
+ };
+ List> executeResult;
+ if (queryWay.compareTo(QueryWay.ByStock) >= 0) {
+ executeResult = tushareCrawler.rollingQueryByStock(baseRequest, stockInfoService.list(), this,
+ function, 500L, queryWay, null);
+ } else {
+ executeResult = tushareCrawler.rollingQueryByDate(baseRequest, this, function, queryWay,
+ LocalDateTime.of(2019, 11, 28, 0, 0, 0));
+ }
+ log.info("涨跌停数据更新完成");
+ for (Future f : executeResult) {
+ try {
+ TushareCrawlerResult result = f.get();
+ JSONObject request = f.get().getRequest();
+ if (result.isFatal()) {
+ log.error("涨跌停数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
+ } else if (!result.isSuccess()) {
+ log.info("涨跌停数据未获取:{}", request.toJSONString());
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ log.error("检查涨跌停数据执行结果时发生错误", e);
+ }
+ }
+ if (baseRequest.hasFingerprint()) {
+ redis.getBucket(baseRequest.getFingerprint()).delete();
+ }
+ return true;
+ }
+
+ /**
+ * 插入从 Tushare 抓取的内容
+ *
+ * @param list
+ * @return
+ */
+ public boolean insertOrUpdateList(List list) {
+ return SqlHelper.retBool(baseMapper.insertOrUpdateList(list));
+ }
+
+ @Override
+ public LocalDateTime getLatestTradeDate(ITsStockInfo stockInfo) {
+ return baseMapper.getLatestTradeDate(stockInfo);
+ }
+
+ @Override
+ public List getAllTradeDates(ITsStockInfo stockInfo) {
+ return baseMapper.getAllTradeDates(stockInfo);
+ }
+
+ @Override
+ public List getAllMissingDates(ITsStockInfo stockInfo) {
+ return baseMapper.getAllMissingDates(stockInfo);
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/service/StockMinuteService.java b/src/main/java/link/at17/mid/tushare/data/service/StockMinuteService.java
new file mode 100644
index 0000000..6f3baea
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/service/StockMinuteService.java
@@ -0,0 +1,200 @@
+package link.at17.mid.tushare.data.service;
+
+import java.time.LocalDateTime;
+import java.time.temporal.Temporal;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.function.Function;
+
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.Assert;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
+
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.dao.StockMinuteDao;
+import link.at17.mid.tushare.data.crawler.QueryWay;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawler;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawlerResult;
+import link.at17.mid.tushare.data.crawler.tushare.TushareRequestBody;
+import link.at17.mid.tushare.data.models.StockValue;
+import link.at17.mid.tushare.data.models.StockValueEx;
+import link.at17.mid.tushare.data.models.interfaces.ITsStockInfo;
+import link.at17.mid.tushare.data.models.interfaces.ITsTradeDate;
+import link.at17.mid.tushare.data.validator.AllowedEnum;
+import link.at17.mid.tushare.enums.StockSpan;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Slf4j
+public class StockMinuteService {
+
+ @Autowired
+ StockMinuteDao stockMinuteDao;
+
+ @Autowired
+ StockInfoService stockInfoService;
+
+ @Autowired
+ TushareCrawler tushareCrawler;
+
+ @Autowired
+ RedissonClient redis;
+
+ /**
+ * 更新由 Tushare 获取的数据
+ *
+ * @param list
+ * @return
+ */
+ public boolean insertOrUpdateList(List list, StockSpan stockSpan) {
+ return SqlHelper.retBool(stockMinuteDao.insertOrUpdateList(list, stockSpan));
+ }
+
+ /**
+ * 获取最新交易日
+ * 获取到股票的最新交易日
+ *
+ * @param stockSpan 分钟线频率
+ * @param stockInfo
+ * @return
+ */
+ public LocalDateTime getLatestTradeDate(StockSpan stockSpan, ITsStockInfo stockInfo) {
+ return stockMinuteDao.getLatestTradeDate(stockSpan, stockInfo);
+ }
+
+ /**
+ * 获取指定 freq 下的所有交易日
+ *
+ * @param stockSpan 分钟线频率
+ * @param stockInfo
+ * @return
+ */
+ public List getAllTradeDates(StockSpan stockSpan, ITsStockInfo stockInfo) {
+ return stockMinuteDao.getAllTradeDates(stockSpan, stockInfo);
+ }
+
+ /**
+ * 获取指定 freq 下的数据缺失日,包括分钟数据不全日
+ * 如:60 分钟频率下,一日内 K 线数应为 240/60 + 1 = 5 条,则小于 5 条的日期都将被列为缺失日期
+ *
+ * @param stockSpan 分钟线频率
+ * @param stockInfo
+ * @return
+ */
+ public List getAllMissingDates(StockSpan stockSpan, ITsStockInfo stockInfo) {
+ return stockMinuteDao.getAllMissingDates(stockSpan, stockInfo);
+ }
+
+ /**
+ * 获取前复权日线数据
+ *
+ * @param stockCode 股票代码,不允许为空
+ * @param stockSpan 分钟线频率
+ * @param startDate 开始日期,留空则为股票上市日起
+ * @param endDate 结束日期,留空则为最新一个交易日
+ * @return
+ */
+ public List getQfqMinute(String stockCode, StockSpan stockSpan, Temporal startDate, Temporal endDate) {
+ return stockMinuteDao.getQfqMinute(stockCode, stockSpan, startDate, endDate);
+ }
+
+ /**
+ * 获取前复权日线数据
+ *
+ * @param stockCode 股票代码,不允许为空
+ * @param endDate 结束日期,留空则为最新一个交易日
+ * @param before 多少个交易日以前,留空则查询上市以来所有数据
+ * @return
+ */
+ public List getQfqMinuteBefore(String stockCode, StockSpan stockSpan, Temporal endDate, Long before) {
+ return stockMinuteDao.getQfqMinuteBefore(stockCode, stockSpan, endDate, before);
+ }
+
+ /**
+ * 获取前复权日线 Ex 数据
+ *
+ * @param stockCode 股票代码,不允许为空
+ * @param endDate 结束日期,留空则为最新一个交易日
+ * @param before 多少个交易日以前,留空则查询上市以来所有数据
+ * @return
+ */
+ public List getExQfqMinuteBefore(String stockCode, StockSpan stockSpan, Temporal endDate,
+ Long before) {
+ return stockMinuteDao.getExQfqMinuteBefore(stockCode, stockSpan, endDate, before);
+ }
+
+ /**
+ * 更新分钟K数据
+ *
+ * 有权限时,每分钟500次,每次8000行数据,总量不限制
+ *
+ * 请先更新交易日历和股票列表后再调用该方法,否则可能造成股票分钟 K 数据缺失
+ *
+ * @param queryWay 滚动查询方式,仅支持 StockSpan.ByStock... 系列
+ * @param stockSpan 股票粒度,仅支持 StockSpan.Minute - StockSpan.SixtyMinute
+ * @see TushareCrawler#rollingQueryByDate
+ * @see TushareCrawler#rollingQueryByStock
+ * @see link.at17.mid.tushare.enums.StockSpan
+ */
+ @UpdateMethod(name="分钟 K 数据", order=9)
+ public boolean updateData(
+ @AllowedEnum({"ByDateUpdate", "ByStockCrossCheck", "ByDateAll" })
+ QueryWay queryWay,
+ @AllowedEnum({"Minute", "Minute5", "Minute15", "Minute30", "Minute60"})
+ StockSpan stockSpan) {
+ Assert.isTrue(Objects.nonNull(stockSpan), "stockSpan 不允许为空");
+ Assert.isTrue(Objects.nonNull(stockSpan.getMin()), "不支持的 StockSpan:" + stockSpan + ", 仅支持分钟数据类型");
+ Assert.isTrue(queryWay.compareTo(QueryWay.ByStock) >= 0, "不支持的 QueryWay!");
+ String freq = stockSpan.getMin() + "min";
+ TushareRequestBody baseRequest = new TushareRequestBody("stk_mins")
+ .addFields("ts_code,trade_time,open,close,high,low,vol,amount").addParam("freq", freq);
+ Function, Boolean> function = (t) -> {
+ stockMinuteDao.insertOrUpdateList(t, stockSpan);
+ return true;
+ };
+ // 特殊重写:获取最新日期和所有交易日,该重写仅对更新数据生效
+ ITsTradeDate minTradeDate = new ITsTradeDate() {
+ @Override
+ public LocalDateTime getLatestTradeDate(ITsStockInfo stockInfo) {
+ return stockMinuteDao.getLatestTradeDate(stockSpan, stockInfo);
+ }
+
+ @Override
+ public List getAllTradeDates(ITsStockInfo stockInfo) {
+ return stockMinuteDao.getAllTradeDates(stockSpan, stockInfo);
+ }
+
+ @Override
+ public List getAllMissingDates(ITsStockInfo stockInfo) {
+ return stockMinuteDao.getAllMissingDates(stockSpan, stockInfo);
+ }
+ };
+ List> executeResult = tushareCrawler.rollingQueryByStock(baseRequest,
+ stockInfoService.list(), minTradeDate, function, 8000L, queryWay, stockSpan);
+ log.info("{} 分钟 K 数据更新完成", stockSpan.getMin());
+ for (Future f : executeResult) {
+ try {
+ TushareCrawlerResult result = f.get();
+ JSONObject request = f.get().getRequest();
+ if (result.isFatal()) {
+ log.error("{} 分钟 K 数据未获取:{},发生致命错误,将不会重试:{}", stockSpan.getMin(), request.toJSONString());
+ } else if (!result.isSuccess()) {
+ log.warn("{} 分钟 K 数据未获取:{}", stockSpan.getMin(), request.toJSONString());
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ log.error("检查 {} 分钟 K 数据执行结果时发生错误\r\n{}", stockSpan.getMin(), e);
+ }
+ }
+ if (baseRequest.hasFingerprint()) {
+ redis.getBucket(baseRequest.getFingerprint()).delete();
+ }
+ return true;
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/service/ThsDailyService.java b/src/main/java/link/at17/mid/tushare/data/service/ThsDailyService.java
new file mode 100644
index 0000000..2e34b8c
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/service/ThsDailyService.java
@@ -0,0 +1,100 @@
+package link.at17.mid.tushare.data.service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.function.Function;
+
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
+
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.dao.ThsDailyDao;
+import link.at17.mid.tushare.dao.ThsListDao;
+import link.at17.mid.tushare.data.crawler.QueryWay;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawler;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawlerResult;
+import link.at17.mid.tushare.data.crawler.tushare.TushareRequestBody;
+import link.at17.mid.tushare.data.validator.AllowedEnum;
+import link.at17.mid.tushare.enums.ThsStockMarket;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Slf4j
+public class ThsDailyService {
+
+ private static final LocalDateTime THS_DAILY_BEGIN_LOCALDATE = LocalDateTime.of(2007, 8, 1, 0, 0, 0);
+
+ @Autowired
+ ThsDailyDao thsDailyDao;
+
+ @Autowired
+ ThsListDao thsListDao;
+
+ @Autowired
+ TushareCrawler tushareCrawler;
+
+ @Autowired
+ RedissonClient redis;
+
+ /**
+ * 同花顺板块指数日线行情
+ * 接口:ths_daily
+ * 描述:获取同花顺板块指数行情。注:数据版权归属同花顺,如做商业用途,请主动联系同花顺,如需帮助请联系微信:waditu_a
+ * 限量:单次最大3000行数据(需6000积分),可根据指数代码、日期参数循环提取。
+ * @param queryWay
+ * @return
+ */
+ @UpdateMethod(name="同花顺板块指数日线行情", order=8)
+ public boolean updateData(
+ @AllowedEnum({"ByDateUpdate", "ByStockCrossCheck"})
+ QueryWay queryWay) {
+ TushareRequestBody baseRequest = new TushareRequestBody("ths_daily").addFields("ts_code,trade_date,close,open,high,low,pre_close,avg_price,change,pct_change,vol,turnover_rate,total_mv,float_mv");
+ Function, Boolean> function = (t) -> {
+ thsDailyDao.insertOrUpdateList(t);
+ return true;
+ };
+
+ List> executeResult;
+ if (queryWay.compareTo(QueryWay.ByStock) >= 0) {
+ executeResult = tushareCrawler.rollingQueryByStock(baseRequest, thsListDao.listByExchange(ThsStockMarket.A), thsDailyDao, function, 3000L, queryWay, null);
+ }
+ else {
+ executeResult = tushareCrawler.rollingQueryByDate(baseRequest, thsDailyDao, function, queryWay, THS_DAILY_BEGIN_LOCALDATE);
+ }
+ log.info("同花顺板块指数行情更新完成");
+ for (Future f : executeResult) {
+ try {
+ TushareCrawlerResult result = f.get();
+ JSONObject request = result.getRequest();
+ if (result.isFatal()) {
+ log.error("同花顺板块指数行情数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
+ }
+ else if (!result.isSuccess()) {
+ log.warn("同花顺板块指数行情数据未获取:{}", request.toJSONString());
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ log.error("同花顺板块指数行情数据执行结果时发生错误", e);
+ }
+ }
+ if (baseRequest.hasFingerprint()) {
+ redis.getBucket(baseRequest.getFingerprint()).delete();
+ }
+ return true;
+ }
+
+ /**
+ * 插入从 Tushare 获取的数据
+ * @param list
+ * @return 插入是否成功
+ */
+ public boolean insertOrUpdateList(List list) {
+ return SqlHelper.retBool(thsDailyDao.insertOrUpdateList(list));
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/service/ThsListService.java b/src/main/java/link/at17/mid/tushare/data/service/ThsListService.java
new file mode 100644
index 0000000..3be42a7
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/service/ThsListService.java
@@ -0,0 +1,51 @@
+package link.at17.mid.tushare.data.service;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
+
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.dao.ThsListDao;
+import link.at17.mid.tushare.data.crawler.tushare.TushareClient;
+import link.at17.mid.tushare.data.crawler.tushare.TushareRequestBody;
+import link.at17.mid.tushare.data.models.ThsStockInfo;
+import link.at17.mid.tushare.service.BaseServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Slf4j
+public class ThsListService extends BaseServiceImpl {
+
+ /**
+ * 同花顺概念和行业指数
+ * 接口:ths_index
+ * 描述:获取同花顺板块指数。注:数据版权归属同花顺,如做商业用途,请主动联系同花顺,如需帮助请联系微信migedata 。
+ * 限量:本接口需获得600积分,单次最大5000,一次可提取全部数据,请勿循环提取。
+ * @return
+ */
+ @UpdateMethod(name="同花顺板块指数列表", order=6)
+ public boolean updateData() {
+ try {
+ List thsIndexes = TushareClient.queryList(new TushareRequestBody("ths_index"));
+ insertOrUpdateList(thsIndexes);
+ log.info("更新同花顺板块列表完成");
+ return true;
+ } catch (Exception e) {
+ log.error("更新同花顺板块列表时发生错误", e);
+ return false;
+ }
+ }
+
+ /**
+ * 插入从 Tushare 获取的数据
+ * @param list
+ * @return 插入是否成功
+ */
+ public boolean insertOrUpdateList(List list) {
+ return SqlHelper.retBool(baseMapper.insertOrUpdateList(list));
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/service/ThsMemberService.java b/src/main/java/link/at17/mid/tushare/data/service/ThsMemberService.java
new file mode 100644
index 0000000..4e649be
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/service/ThsMemberService.java
@@ -0,0 +1,96 @@
+package link.at17.mid.tushare.data.service;
+
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.function.Function;
+
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
+
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.dao.ThsListDao;
+import link.at17.mid.tushare.dao.ThsMemberDao;
+import link.at17.mid.tushare.data.crawler.QueryWay;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawler;
+import link.at17.mid.tushare.data.crawler.tushare.TushareCrawlerResult;
+import link.at17.mid.tushare.data.crawler.tushare.TushareRequestBody;
+import link.at17.mid.tushare.data.models.ThsStockInfo;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Slf4j
+public class ThsMemberService {
+
+ @Autowired
+ ThsMemberDao thsMemberDao;
+
+ @Autowired
+ ThsListDao thsListDao;
+
+ @Autowired
+ TushareCrawler tushareCrawler;
+
+ @Autowired
+ RedissonClient redis;
+
+
+ /**
+ * 同花顺概念板块成分
+ * 接口:ths_member
+ * 描述:获取同花顺概念板块成分列表注:数据版权归属同花顺,如做商业用途,请主动联系同花顺。
+ * 限量:用户积累5000积分可调取,每分钟可调取200次,可按概念板块代码循环提取所有成分
+ */
+ @UpdateMethod(name="同花顺概念板块成分", order=7)
+ public boolean updateData() {
+ TushareRequestBody baseRequest = new TushareRequestBody("ths_member").addFields("ts_code,con_code,con_name,weight,in_date,out_date,is_new");
+ Function, Boolean> function = (t) -> {
+ thsMemberDao.insertOrUpdateList(t);
+ return true;
+ };
+ List stockInfoList = thsListDao.listByExchange(null);
+ while (true) {
+ List> executeResult = tushareCrawler.rollingQueryByStock(baseRequest, stockInfoList, null, function, 5000L, QueryWay.ByStock, null);
+
+ stockInfoList.clear();
+ for (Future f : executeResult) {
+ try {
+ TushareCrawlerResult result = f.get();
+ JSONObject request = result.getRequest();
+ if (result.isFatal()) {
+ log.error("同花顺概念板块成分数据未获取,发生致命错误,将不会重试:{}", request.toJSONString());
+ }
+ else if (!result.isSuccess()) {
+ log.warn("同花顺概念板块成分数据未获取:{}", request.toJSONString());
+ stockInfoList.add(request.getJSONObject("params").to(ThsStockInfo.class));
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ log.error("同花顺概念板块成分数据执行结果时发生错误", e);
+ }
+ }
+ if (stockInfoList.size() == 0) {
+ log.info("同花顺概念板块成分更新完成");
+ break;
+ }
+ log.info("重新获取未获取成功的同花顺概念板块成分");
+ }
+ if (baseRequest.hasFingerprint()) {
+ redis.getBucket(baseRequest.getFingerprint()).delete();
+ }
+ return true;
+ }
+
+ /**
+ * 插入从 Tushare 获取的数据
+ * @param list
+ * @return 插入是否成功
+ */
+ public boolean insertOrUpdateList(List list) {
+ return SqlHelper.retBool(thsMemberDao.insertOrUpdateList(list));
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/typehandler/JsonListTypeHandler.java b/src/main/java/link/at17/mid/tushare/data/typehandler/JsonListTypeHandler.java
new file mode 100644
index 0000000..11bddfb
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/typehandler/JsonListTypeHandler.java
@@ -0,0 +1,67 @@
+package link.at17.mid.tushare.data.typehandler;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.ibatis.type.MappedJdbcTypes;
+import org.apache.ibatis.type.MappedTypes;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+
+@MappedTypes(List.class)
+@MappedJdbcTypes(JdbcType.VARCHAR)
+@Slf4j
+public class JsonListTypeHandler extends BaseTypeHandler> {
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ @Override
+ public void setNonNullParameter(PreparedStatement ps, int i, List> parameter, JdbcType jdbcType) throws SQLException {
+ try {
+ ps.setString(i, mapper.writeValueAsString(parameter));
+ } catch (JsonProcessingException | SQLException e) {
+ log.error("将 {} 转换成 JSON String 并存储时错误", parameter, e);
+ }
+ }
+
+ @Override
+ public List> getNullableResult(ResultSet rs, String columnName) throws SQLException {
+ String json = rs.getString(columnName);
+ try {
+ return mapper.readValue(json, new TypeReference>() {});
+ } catch (JsonProcessingException e) {
+ log.error("将 JSON String {} 转换成 List> 时错误", json, e);
+ return new ArrayList<>();
+ }
+ }
+
+ @Override
+ public List> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+ try {
+ return mapper.readValue(rs.getString(columnIndex), new TypeReference>() {});
+ } catch (JsonProcessingException | SQLException e) {
+ log.error("将 ResultSet {} 转换成 List> 时错误", rs, e);
+ return new ArrayList<>();
+ }
+ }
+
+ @Override
+ public List> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+ try {
+ return mapper.readValue(cs.getString(columnIndex), new TypeReference>() {});
+ } catch (JsonProcessingException | SQLException e) {
+ log.error("将 CallableStatement {} 转换成 List> 时错误", cs, e);
+ return new ArrayList<>();
+ }
+ }
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/typehandler/UpdateMethodInfoListTypeHandler.java b/src/main/java/link/at17/mid/tushare/data/typehandler/UpdateMethodInfoListTypeHandler.java
new file mode 100644
index 0000000..ac77a3e
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/typehandler/UpdateMethodInfoListTypeHandler.java
@@ -0,0 +1,40 @@
+package link.at17.mid.tushare.data.typehandler;
+
+import java.util.List;
+
+import org.apache.ibatis.type.MappedJdbcTypes;
+import org.apache.ibatis.type.MappedTypes;
+
+import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import link.at17.mid.tushare.data.models.UpdateMethodInfo;
+import lombok.extern.slf4j.Slf4j;
+
+import org.apache.ibatis.type.JdbcType;
+
+@MappedTypes(List.class)
+@MappedJdbcTypes(JdbcType.VARCHAR)
+@Slf4j
+public class UpdateMethodInfoListTypeHandler extends AbstractJsonTypeHandler> {
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ @Override
+ protected List parse(String json) {
+ try {
+ return mapper.readValue(json, new TypeReference>() {});
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ protected String toJson(List obj) {
+ try {
+ return mapper.writeValueAsString(obj);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/data/util/CryptoUtil.java b/src/main/java/link/at17/mid/tushare/data/util/CryptoUtil.java
deleted file mode 100644
index 7388bb0..0000000
--- a/src/main/java/link/at17/mid/tushare/data/util/CryptoUtil.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package link.at17.mid.tushare.data.util;
-
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-
-import org.apache.commons.codec.binary.Hex;
-
-public class CryptoUtil {
-
- public static String getSHA256Str(String str) {
- MessageDigest messageDigest;
- String encdeStr = "";
- try {
- messageDigest = MessageDigest.getInstance("SHA-256");
- byte[] hash = messageDigest.digest(str.getBytes(StandardCharsets.UTF_8));
- encdeStr = Hex.encodeHexString(hash);
- } catch (NoSuchAlgorithmException e) {}
- return encdeStr;
- }
-}
diff --git a/src/main/java/link/at17/mid/tushare/data/validator/AllowedEnum.java b/src/main/java/link/at17/mid/tushare/data/validator/AllowedEnum.java
new file mode 100644
index 0000000..fb8da0f
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/validator/AllowedEnum.java
@@ -0,0 +1,19 @@
+package link.at17.mid.tushare.data.validator;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+@Constraint(validatedBy = AllowedEnumValidator.class)
+public @interface AllowedEnum {
+ String message() default "非法枚举值";
+ Class>[] groups() default {};
+ Class extends Payload>[] payload() default {};
+ String[] value(); // 允许的枚举名
+}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/data/validator/AllowedEnumValidator.java b/src/main/java/link/at17/mid/tushare/data/validator/AllowedEnumValidator.java
new file mode 100644
index 0000000..36641a7
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/validator/AllowedEnumValidator.java
@@ -0,0 +1,23 @@
+package link.at17.mid.tushare.data.validator;
+
+import java.util.Set;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+/**
+ * 允许枚举的验证器
+ */
+public class AllowedEnumValidator implements IValidator, ConstraintValidator> {
+ private Set allowed;
+
+ @Override
+ public void initialize(AllowedEnum anno) {
+ allowed = Set.of(anno.value());
+ }
+
+ @Override
+ public boolean isValid(Enum> val, ConstraintValidatorContext ctx) {
+ return val == null || allowed.contains(val.name());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/data/validator/CronValidator.java b/src/main/java/link/at17/mid/tushare/data/validator/CronValidator.java
new file mode 100644
index 0000000..2cf43f9
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/validator/CronValidator.java
@@ -0,0 +1,18 @@
+package link.at17.mid.tushare.data.validator;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import org.springframework.scheduling.support.CronExpression;
+
+public class CronValidator implements ConstraintValidator {
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ if (value == null || value.isBlank()) return true; // 空值交由 @NotBlank 处理
+ try {
+ CronExpression.parse(value);
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/data/validator/IValidator.java b/src/main/java/link/at17/mid/tushare/data/validator/IValidator.java
new file mode 100644
index 0000000..d3d0b76
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/validator/IValidator.java
@@ -0,0 +1,17 @@
+package link.at17.mid.tushare.data.validator;
+
+import jakarta.validation.ConstraintValidatorContext;
+
+/**
+ * 快速提供自定义错误信息返回
+ */
+public interface IValidator {
+
+ public default boolean invalid(ConstraintValidatorContext context, String message) {
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate(message)
+ .addConstraintViolation();
+ return false;
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/validator/UpdateMethodInfoValidator.java b/src/main/java/link/at17/mid/tushare/data/validator/UpdateMethodInfoValidator.java
new file mode 100644
index 0000000..25c113d
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/validator/UpdateMethodInfoValidator.java
@@ -0,0 +1,63 @@
+package link.at17.mid.tushare.data.validator;
+
+import java.util.List;
+import java.util.Objects;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import link.at17.mid.tushare.data.models.UpdateMethodInfo;
+import link.at17.mid.tushare.data.models.UpdateMethodInfo.UpdateParamInfo;
+import link.at17.mid.tushare.service.UpdateMethodService;
+
+@Component
+public class UpdateMethodInfoValidator implements IValidator, ConstraintValidator {
+
+ @Autowired
+ private UpdateMethodService updateMethodService; // 内存维护的合法方法表
+
+ @Override
+ public boolean isValid(UpdateMethodInfo methodInfo, ConstraintValidatorContext context) {
+ if (methodInfo == null) return true;
+
+ // 1. 校验 id 是否存在
+ if (methodInfo.getId() == null) {
+ return invalid(context, "方法 id 为空");
+ }
+
+ // 2. 校验 id 在内存中是否存储
+ UpdateMethodInfo validMethod = updateMethodService.findById(methodInfo.getId());
+ if (validMethod == null) {
+ return invalid(context, "方法 id 不存在: " + methodInfo.getId());
+ }
+
+ String name = validMethod.getName();
+
+ // 3. 校验参数数量与名称
+ List incoming = methodInfo.getParams();
+ List reference = validMethod.getParams();
+
+ if (incoming == null || incoming.size() != reference.size()) {
+ return invalid(context, name + " 参数数量不一致: " + methodInfo.getId());
+ }
+
+ for (int i = 0; i < incoming.size(); i++) {
+ UpdateParamInfo in = incoming.get(i);
+ UpdateParamInfo ref = reference.get(i);
+
+ if (!Objects.equals(in.getName(), ref.getName())) {
+ return invalid(context, name + " 参数名不匹配: " + in.getName());
+ }
+
+ if (ref.getAllowedEnumValues() != null &&
+ in.getValue() != null &&
+ !ref.getAllowedEnumValues().contains(String.valueOf(in.getValue()))) {
+ return invalid(context, name + " 参数值非法: " + in.getName() + " : " + in.getValue());
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/main/java/link/at17/mid/tushare/data/validator/ValidCron.java b/src/main/java/link/at17/mid/tushare/data/validator/ValidCron.java
new file mode 100644
index 0000000..19fc625
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/validator/ValidCron.java
@@ -0,0 +1,17 @@
+package link.at17.mid.tushare.data.validator;
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+import java.lang.annotation.*;
+
+@Documented
+@Constraint(validatedBy = CronValidator.class)
+@Target({ ElementType.FIELD, ElementType.PARAMETER })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ValidCron {
+
+ String message() default "无效的 Cron 表达式";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/data/validator/ValidUpdateMethodInfo.java b/src/main/java/link/at17/mid/tushare/data/validator/ValidUpdateMethodInfo.java
new file mode 100644
index 0000000..21f98cc
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/data/validator/ValidUpdateMethodInfo.java
@@ -0,0 +1,20 @@
+package link.at17.mid.tushare.data.validator;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+@Target({ElementType.TYPE, ElementType.TYPE_USE})
+@Retention(RetentionPolicy.RUNTIME)
+@Constraint(validatedBy = UpdateMethodInfoValidator.class)
+@Documented
+public @interface ValidUpdateMethodInfo {
+ String message() default "非法的 UpdatePlan";
+ Class>[] groups() default {};
+ Class extends Payload>[] payload() default {};
+}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/dto/JsonViews.java b/src/main/java/link/at17/mid/tushare/dto/JsonViews.java
new file mode 100644
index 0000000..8c745b6
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/dto/JsonViews.java
@@ -0,0 +1,6 @@
+package link.at17.mid.tushare.dto;
+
+public class JsonViews {
+ public static class Public {} // 给前端用
+ public static class Internal {} // 存库用
+}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/dto/LayPageReq.java b/src/main/java/link/at17/mid/tushare/dto/LayPageReq.java
new file mode 100644
index 0000000..911e3e8
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/dto/LayPageReq.java
@@ -0,0 +1,43 @@
+package link.at17.mid.tushare.dto;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+/**
+ * 后台管理分页查询,适配 layui
+ * @author Doghole
+ *
+ * @param
+ */
+@Accessors(chain = true)
+@Data
+@EqualsAndHashCode(callSuper=false)
+public class LayPageReq extends Page {
+
+ private static final long serialVersionUID = 3471995935637905622L;
+
+ public LayPageReq setPage(Long page) {
+ current = page;
+ return this;
+ }
+
+ public Long getPage() {
+ return current;
+ }
+
+ public LayPageReq setLimit(Long limit) {
+ size = limit;
+ return this;
+ }
+
+ public Long getLimit() {
+ return size;
+ }
+
+ Integer start;
+
+ T condition;
+}
diff --git a/src/main/java/link/at17/mid/tushare/enums/ListStatus.java b/src/main/java/link/at17/mid/tushare/enums/ListStatus.java
index 3e3efdf..e441418 100644
--- a/src/main/java/link/at17/mid/tushare/enums/ListStatus.java
+++ b/src/main/java/link/at17/mid/tushare/enums/ListStatus.java
@@ -5,8 +5,17 @@ import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.Getter;
public enum ListStatus {
+ /**
+ * 上市
+ */
LIST("L"),
+ /**
+ * 退市
+ */
DELIST("D"),
+ /**
+ * 停牌
+ */
PAUSE("P");
@Getter
diff --git a/src/main/java/link/at17/mid/tushare/enums/UpdateLogType.java b/src/main/java/link/at17/mid/tushare/enums/UpdateLogType.java
new file mode 100644
index 0000000..529547f
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/enums/UpdateLogType.java
@@ -0,0 +1,9 @@
+package link.at17.mid.tushare.enums;
+
+public enum UpdateLogType {
+
+ INFO,
+ ERROR,
+ SUCCESS
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/service/BaseServiceImpl.java b/src/main/java/link/at17/mid/tushare/service/BaseServiceImpl.java
new file mode 100644
index 0000000..962534d
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/service/BaseServiceImpl.java
@@ -0,0 +1,88 @@
+/**
+ *
+ */
+package link.at17.mid.tushare.service;
+
+import java.util.List;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.github.yulichang.base.MPJBaseMapper;
+import com.github.yulichang.base.MPJBaseServiceImpl;
+import com.github.yulichang.interfaces.MPJBaseJoin;
+
+/**
+ * BaseServiceImpl
+ *
基本业务实现
+ *
支持联查 Wrapper: {@code MPJLambdaWrapper}
+ * @author Doghole
+ */
+public abstract class BaseServiceImpl, T> extends MPJBaseServiceImpl {
+
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ @Override
+ public T getOne(Wrapper wrapper) {
+ return (wrapper instanceof MPJBaseJoin) ?
+ (T) selectJoinOne(currentModelClass(), (MPJBaseJoin)wrapper) : super.getOne(wrapper);
+ }
+
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ @Override
+ public long count(Wrapper wrapper) {
+ return (wrapper instanceof MPJBaseJoin) ?
+ baseMapper.selectJoinCount((MPJBaseJoin) wrapper): super.count(wrapper);
+ }
+
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ @Override
+ public List list(Wrapper wrapper) {
+ return (wrapper instanceof MPJBaseJoin) ?
+ baseMapper.selectJoinList(currentModelClass(), (MPJBaseJoin)wrapper): super.list(wrapper);
+ }
+
+
+ /**
+ * 给子类用的方法
+ * @param
+ * @param clazz
+ * @param wrapper
+ * @return
+ */
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ public E getOneJoin(Class clazz, MPJBaseJoin wrapper) {
+ return (E) baseMapper.selectJoinOne(clazz, (MPJBaseJoin)wrapper);
+ }
+
+ /**
+ * 给子类用的方法
+ * @param
+ * @param clazz
+ * @param wrapper
+ * @return
+ */
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ public List listJoin(Class clazz, MPJBaseJoin wrapper) {
+ return baseMapper.selectJoinList(clazz, (MPJBaseJoin)wrapper);
+ }
+
+ /**
+ * 基于 Mybatis-Plus 的分页,返回对应 Page 和 Wrapper 的 Page
+ */
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ @Override
+ public > E page(E page, Wrapper wrapper) {
+ return (wrapper instanceof MPJBaseJoin) ?
+ (E) baseMapper.selectJoinPage(page, this.currentModelClass(), (MPJBaseJoin)wrapper):
+ super.page(page, wrapper);
+ }
+
+ /**
+ * 基于 Mybatis-Plus 的分页,返回对应 Page<T> 和 Wrapper<T> 的 Page
+ * 给子类用的方法
+ */
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ public , S extends T> E joinPage(E page, Class clazz, MPJBaseJoin wrapper) {
+ return (E) baseMapper.selectJoinPage(page, clazz, (MPJBaseJoin)wrapper);
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/service/StatService.java b/src/main/java/link/at17/mid/tushare/service/StatService.java
new file mode 100644
index 0000000..6d71cd7
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/service/StatService.java
@@ -0,0 +1,11 @@
+package link.at17.mid.tushare.service;
+
+import org.springframework.stereotype.Service;
+
+/**
+ * 统计和状态服务
+ */
+@Service
+public class StatService {
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/service/UpdateMethodService.java b/src/main/java/link/at17/mid/tushare/service/UpdateMethodService.java
new file mode 100644
index 0000000..bfa342b
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/service/UpdateMethodService.java
@@ -0,0 +1,194 @@
+package link.at17.mid.tushare.service;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.reflections.Reflections;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import jakarta.annotation.PostConstruct;
+import link.at17.mid.tushare.annotation.UpdateMethod;
+import link.at17.mid.tushare.data.models.UpdateMethodInfo;
+import link.at17.mid.tushare.data.models.UpdateMethodInfo.UpdateParamInfo;
+import link.at17.mid.tushare.data.validator.AllowedEnum;
+import link.at17.mid.tushare.system.util.SpringBeanDetector;
+import link.at17.mid.tushare.system.util.SpringContextHolder;
+import lombok.extern.slf4j.Slf4j;
+
+
+@Service
+@Slf4j
+@Lazy(false)
+@Validated
+public class UpdateMethodService {
+
+ @Autowired
+ Reflections reflections;
+
+ /**
+ * 存放初始化 UpdateMethodInfo 的缓存,主要用于通过 id 来找到原始记录
+ */
+ Map updateMethodInfoCaches = new LinkedHashMap<>();
+
+ @PostConstruct
+ void postConstruct() {
+
+ // 从 reflections 拿信息
+ List updateMethods =
+ new ArrayList<>(reflections.getMethodsAnnotatedWith(UpdateMethod.class));
+ updateMethods.sort(Comparator.comparingInt(m -> m.getAnnotation(UpdateMethod.class).order()));
+
+ methodLabel:
+ for (Method method : updateMethods) {
+
+ UpdateMethodInfo info = new UpdateMethodInfo();
+
+ // 获取 UpdateMethod 注解基本信息
+ UpdateMethod um = method.getAnnotation(UpdateMethod.class);
+ String name = um.name();
+ info.setName(name);
+ info.setMethodName(method.getName());
+ Class> declaringClass = method.getDeclaringClass();
+ info.setDeclaringClassName(declaringClass.getName());
+
+ // 判断该 Class 是否交由 Spring 管理
+ boolean managedBySpring = SpringBeanDetector.isSpringManagedClass(declaringClass);
+ if (!managedBySpring) {
+ // TODO: 非 Spring 管理类的成员方法 / 静态方法的数据更新?
+ log.warn("方法 {} 所属类 {} 不归属于 Spring 管理,目前暂不支持作为更新服务候选项",
+ method.getName(), declaringClass.getSimpleName());
+ continue;
+ }
+
+ // 获取该方法的所有参数并读取
+ List paramInfos = new ArrayList<>();
+ for (Parameter p : method.getParameters()) {
+
+ UpdateMethodInfo.UpdateParamInfo updateParamInfo = new UpdateMethodInfo.UpdateParamInfo();
+ updateParamInfo.setName(p.getName());
+ Class> parameterType = p.getType();
+ updateParamInfo.setFullTypeName(parameterType.getName());
+ updateParamInfo.setTypeName(parameterType.getSimpleName());
+ updateParamInfo.setTypeClass(parameterType);
+
+ if (parameterType.isEnum()) {
+
+ // 当前枚举下的所有枚举值
+ List allEnums = Arrays.stream(parameterType.getEnumConstants())
+ .map(Object::toString)
+ .toList();
+
+ AllowedEnum allowedEnum = p.getAnnotation(AllowedEnum.class);
+ if (allowedEnum == null) {
+ // 未指定 AllowedEnum,将所有枚举放到 allowedEnumValues 里
+ updateParamInfo.setAllowedEnumValues(allEnums);
+ }
+ else {
+ // 指定了 AllowedEnum,将允许的枚举放到 allowedEnumValues 里
+ String[] allowedEnums = allowedEnum.value();
+ List allowedEnumValues = new ArrayList<>();
+ for (String specificEnum : allowedEnums) {
+ if (allEnums.contains(specificEnum)) {
+ allowedEnumValues.add(specificEnum);
+ }
+ else {
+ log.warn("枚举类 {} 不存在指定的枚举值 {},将忽略", parameterType.getSimpleName(), specificEnum);
+ }
+ }
+ updateParamInfo.setAllowedEnumValues(allowedEnumValues);
+ }
+ paramInfos.add(updateParamInfo);
+ continue;
+ }
+ else {
+ log.warn("方法 {} 参数 {} 非枚举类,目前暂不支持");
+ continue methodLabel;
+ }
+ }
+ info.setParams(paramInfos);
+ updateMethodInfoCaches.put(info.getId(), info);
+ }
+ }
+
+ /**
+ * 获取潜在的数据更新方法。这些方法通过反射扫描得来
+ * @return
+ */
+
+ public List getPotentialUpdateMethodInfos() {
+ return new ArrayList<>(this.updateMethodInfoCaches.values());
+ }
+
+ /**
+ * 根据 UpdateMethodInfo 的 id 查找原始方法模板
+ * @param id
+ * @return
+ */
+ public UpdateMethodInfo findById(String id) {
+ return this.updateMethodInfoCaches.get(id);
+ }
+
+ /**
+ * 填充从外部来的 UpdateMethodInfo
+ *
+ * 注意这里的 incoming 必须是通过 @Valid 校验的
+ * @param incoming
+ */
+ public void fillUpdateMethodInfo(@Validated UpdateMethodInfo incoming) {
+ // 填充 method 信息
+ UpdateMethodInfo valid = findById(incoming.getId());
+ BeanUtils.copyProperties(valid, incoming, "params");
+ if (valid.getParams() == null || valid.getParams().size() == 0) return;
+ for (int i = 0; i < valid.getParams().size(); i++) {
+ UpdateParamInfo in = incoming.getParams().get(i);
+ UpdateParamInfo ref = valid.getParams().get(i);
+ BeanUtils.copyProperties(ref, in, "value");
+ }
+ }
+
+ /**
+ * 执行方法
+ * @param updateMethodInfo
+ * @throws InvocationTargetException
+ * @throws IllegalAccessException
+ * @throws SecurityException
+ * @throws NoSuchMethodException
+ * @throws ClassNotFoundException
+ */
+ @SuppressWarnings("unchecked")
+ public void execute(@Validated UpdateMethodInfo updateMethodInfo) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException, SecurityException, ClassNotFoundException {
+ Class> declaringClass = Class.forName(updateMethodInfo.getDeclaringClassName());
+ Object declaringClassInstance = SpringContextHolder.getBean(declaringClass);
+ boolean hasParams = !updateMethodInfo.getParams().isEmpty();
+ Method method;
+
+ if (hasParams) {
+ @SuppressWarnings("rawtypes")
+ Class[] paramTypes = new Class>[updateMethodInfo.getParams().size()];
+ Object[] paramValues = new Object[updateMethodInfo.getParams().size()];
+ for (int i = 0; i < updateMethodInfo.getParams().size(); i++) {
+ UpdateParamInfo param = updateMethodInfo.getParams().get(i);
+ paramTypes[i] = Class.forName(param.getFullTypeName());
+ // 转换 parameterValue
+ paramValues[i] = Enum.valueOf(paramTypes[i], param.getValue().toString());
+ }
+ method = declaringClass.getDeclaredMethod(updateMethodInfo.getMethodName(), paramTypes);
+ method.invoke(declaringClassInstance, paramValues);
+ }
+ else {
+ method = declaringClass.getDeclaredMethod(updateMethodInfo.getMethodName());
+ method.invoke(declaringClassInstance);
+ }
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/service/UpdatePlanService.java b/src/main/java/link/at17/mid/tushare/service/UpdatePlanService.java
new file mode 100644
index 0000000..0d4a165
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/service/UpdatePlanService.java
@@ -0,0 +1,162 @@
+package link.at17.mid.tushare.service;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.List;
+import java.util.Set;
+
+import org.quartz.CronScheduleBuilder;
+import org.quartz.CronTrigger;
+import org.quartz.JobBuilder;
+import org.quartz.JobDataMap;
+import org.quartz.JobDetail;
+import org.quartz.JobKey;
+import org.quartz.Scheduler;
+import org.quartz.SchedulerException;
+import org.quartz.TriggerBuilder;
+import org.quartz.TriggerKey;
+import org.quartz.impl.matchers.GroupMatcher;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.Resource;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Valid;
+import jakarta.validation.Validator;
+import link.at17.mid.tushare.dao.UpdatePlanDao;
+import link.at17.mid.tushare.data.models.UpdateMethodInfo;
+import link.at17.mid.tushare.data.models.UpdatePlan;
+import link.at17.mid.tushare.task.job.UpdatePlanJob;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@Lazy(false)
+@DependsOn({"updateMethodService", "updateMethodInfoValidator"})
+@Slf4j
+@Validated
+public class UpdatePlanService extends BaseServiceImpl {
+
+ public static final String JOB_GROUP_NAME = "UpdatePlanGroup";
+
+ @Resource(name="scheduler")
+ Scheduler scheduler;
+
+ @Autowired
+ UpdateMethodService updateMethodService;
+
+ @Autowired
+ Validator validator;
+
+ @PostConstruct
+ void postConstruct() throws SchedulerException {
+ // 从数据库加载所有已保存 UpdatePlan 并逐一判断其有效性
+ List updatePlans = list();
+ for (UpdatePlan updatePlan : updatePlans) {
+ Set> v = validator.validate(updatePlan);
+ if (!v.isEmpty()) {
+ v.forEach(violation -> {
+ log.error("UpdatePlan {} 发生错误: {}", updatePlan.getName(), violation.getMessage());
+ });
+ // 设置 UpdatePlan 为无效
+ updatePlan.setValid(false);
+ saveOrUpdate(updatePlan);
+ }
+ }
+ updatePlans = list();
+ updatePlans.forEach(updatePlan -> {
+ try {
+ rescheduleTask(updatePlan);
+ } catch (SchedulerException e) {
+ log.error("从 UpdatePlan 数据表编排任务失败,id = {}, name = {}", updatePlan.getId(), updatePlan.getName(), e);
+ }
+ });
+ }
+
+ /**
+ * 删除指定的任务
+ * @param updatePlan
+ * @throws SchedulerException
+ */
+ public void deleteTask(UpdatePlan updatePlan) throws SchedulerException {
+ deleteTask(updatePlan.getId());
+ }
+
+ /**
+ * 删除指定的任务
+ * @param id UpdatePlan::getId
+ * @throws SchedulerException
+ */
+ public void deleteTask(Integer id) throws SchedulerException {
+ JobKey jobKey = JobKey.jobKey(id.toString(), JOB_GROUP_NAME);
+ if (scheduler.checkExists(jobKey)) {
+ scheduler.deleteJob(jobKey);
+ }
+ }
+
+ /**
+ * 根据 UpdatePlan 实例新建或更新任务
+ * @param updatePlan
+ * @throws SchedulerException
+ */
+ public void rescheduleTask(@Validated UpdatePlan updatePlan) throws SchedulerException {
+ JobKey jobKey = JobKey.jobKey(updatePlan.getId().toString(), JOB_GROUP_NAME);
+ TriggerKey triggerKey = TriggerKey.triggerKey(updatePlan.getId().toString(), JOB_GROUP_NAME);
+
+ deleteTask(updatePlan.getId());
+
+ if (!updatePlan.getEnabled() || !updatePlan.getValid()) {
+ return;
+ }
+
+ JobDataMap jobData = new JobDataMap();
+ jobData.put("updatePlan", updatePlan);
+
+ JobDetail job = JobBuilder.newJob(UpdatePlanJob.class)
+ .withIdentity(jobKey)
+ .usingJobData(jobData)
+ .build();
+
+ CronTrigger trigger = TriggerBuilder.newTrigger()
+ .withIdentity(triggerKey)
+ .withSchedule(CronScheduleBuilder.cronSchedule(updatePlan.getCronExpr()))
+ .build();
+
+ scheduler.scheduleJob(job, trigger);
+ log.debug("已(重新)编排任务 [{}]{}, cronExpr = \"{}\"",
+ updatePlan.getId(), updatePlan.getName(), updatePlan.getCronExpr());
+
+ log.debug("当前所有任务:");
+ Set jobKeys = scheduler.getJobKeys(GroupMatcher.groupEquals(JOB_GROUP_NAME));
+ jobKeys.forEach(jk -> {
+ UpdatePlan relative = getById(Integer.valueOf(jk.getName()));
+ log.debug("UpdatePlan [{}]{}, cronExpr = \"{}\"",
+ relative.getId(), relative.getName(), relative.getCronExpr());
+ });
+ }
+
+ /**
+ * 执行方法
+ * @param updatePlan
+ * @throws ClassNotFoundException
+ * @throws NoSuchMethodException
+ * @throws SecurityException
+ * @throws IllegalAccessException
+ * @throws InvocationTargetException
+ */
+ public void execute(@Valid UpdatePlan updatePlan) throws ClassNotFoundException, NoSuchMethodException, SecurityException, IllegalAccessException, InvocationTargetException {
+ for (UpdateMethodInfo info : updatePlan.getMethods()) {
+ updateMethodService.execute(info);
+ }
+ }
+
+ @Override
+ public List list() {
+ return list(new LambdaQueryWrapper().orderByAsc(UpdatePlan::getId));
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/system/util/SpringBeanDetector.java b/src/main/java/link/at17/mid/tushare/system/util/SpringBeanDetector.java
new file mode 100644
index 0000000..e7c5517
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/system/util/SpringBeanDetector.java
@@ -0,0 +1,44 @@
+package link.at17.mid.tushare.system.util;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.stereotype.*;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.context.annotation.Configuration;
+import org.aspectj.lang.annotation.Aspect;
+
+import java.util.List;
+
+public class SpringBeanDetector {
+
+ @SuppressWarnings("rawtypes")
+ private static final List SPRING_COMPONENT_ANNOTATIONS = List.of(
+ Component.class,
+ Service.class,
+ Repository.class,
+ Controller.class,
+ RestController.class,
+ Configuration.class,
+ ControllerAdvice.class,
+ RestControllerAdvice.class,
+ Aspect.class
+ );
+
+ @SuppressWarnings("unchecked")
+ public static boolean isSpringManagedClass(Class> clazz) {
+ for (@SuppressWarnings("rawtypes") Class ann : SPRING_COMPONENT_ANNOTATIONS) {
+ if (AnnotationUtils.findAnnotation(clazz, ann) != null) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @SuppressWarnings("unchecked")
+ public static String findMatchedAnnotation(Class> clazz) {
+ for (@SuppressWarnings("rawtypes") Class ann : SPRING_COMPONENT_ANNOTATIONS) {
+ if (AnnotationUtils.findAnnotation(clazz, ann) != null) {
+ return ann.getSimpleName();
+ }
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/system/util/SpringContextHolder.java b/src/main/java/link/at17/mid/tushare/system/util/SpringContextHolder.java
index 99cc114..9d181ad 100644
--- a/src/main/java/link/at17/mid/tushare/system/util/SpringContextHolder.java
+++ b/src/main/java/link/at17/mid/tushare/system/util/SpringContextHolder.java
@@ -5,6 +5,7 @@ import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.Validate;
@@ -33,7 +34,15 @@ import lombok.extern.slf4j.Slf4j;
public class SpringContextHolder implements ApplicationContextAware, DisposableBean {
private static ApplicationContext applicationContext = null;
-
+
+ /**
+ * 实现ApplicationContextAware接口, 注入Context到静态变量中.
+ */
+ @Override
+ public void setApplicationContext(ApplicationContext applicationContext) {
+ SpringContextHolder.applicationContext = applicationContext;
+ }
+
/**
* 取得存储在静态变量中的ApplicationContext.
*/
@@ -58,6 +67,14 @@ public class SpringContextHolder implements ApplicationContextAware, DisposableB
assertContextInjected();
return applicationContext.getBean(requiredType);
}
+
+ /**
+ * 从静态变量applicationContext中取得Beans, 自动转型为所赋值对象的类型的 Map.
+ */
+ public static Map getBeansOfType(Class requiredType) {
+ assertContextInjected();
+ return applicationContext.getBeansOfType(requiredType);
+ }
/**
* 清除SpringContextHolder中的ApplicationContext为Null.
@@ -69,13 +86,6 @@ public class SpringContextHolder implements ApplicationContextAware, DisposableB
applicationContext = null;
}
- /**
- * 实现ApplicationContextAware接口, 注入Context到静态变量中.
- */
- @Override
- public void setApplicationContext(ApplicationContext applicationContext) {
- SpringContextHolder.applicationContext = applicationContext;
- }
/**
* 实现DisposableBean接口, 在Context关闭时清理静态变量.
diff --git a/src/main/java/link/at17/mid/tushare/task/TaskSchedulerFactory.java b/src/main/java/link/at17/mid/tushare/task/AutowireCapableJobFactory.java
similarity index 55%
rename from src/main/java/link/at17/mid/tushare/task/TaskSchedulerFactory.java
rename to src/main/java/link/at17/mid/tushare/task/AutowireCapableJobFactory.java
index 9846f95..b758c7f 100644
--- a/src/main/java/link/at17/mid/tushare/task/TaskSchedulerFactory.java
+++ b/src/main/java/link/at17/mid/tushare/task/AutowireCapableJobFactory.java
@@ -5,18 +5,20 @@ import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.scheduling.quartz.AdaptableJobFactory;
import org.springframework.stereotype.Component;
+/**
+ * Job 工厂,使得 Job 实例能获取 Spring 管理的 Bean
+ *
+ * 如某 UpdatePlan implements Job, 在其 execute 方法中需要调用 updatePlanService, 则必须要实例能获取 updatePlanService
+ */
@Component
-public class TaskSchedulerFactory extends AdaptableJobFactory {
-
- // 需要使用这个BeanFactory对Qurartz创建好Job实例进行后续处理,属于Spring的技术范畴.
+public class AutowireCapableJobFactory extends AdaptableJobFactory {
+
@Autowired
private AutowireCapableBeanFactory capableBeanFactory;
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
- // 首先,调用父类的方法创建好Quartz所需的Job实例
Object jobInstance = super.createJobInstance(bundle);
- // 然后,使用BeanFactory为创建好的Job实例进行属性自动装配并将其纳入到Spring容器的管理之中,属于Spring的技术范畴.
- capableBeanFactory.autowireBean(jobInstance);
+ capableBeanFactory.autowireBean(jobInstance); // 自动注入 Spring Bean
return jobInstance;
}
}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/task/job/DailyUpdateDataJob.java b/src/main/java/link/at17/mid/tushare/task/job/DailyUpdateDataJob.java
deleted file mode 100644
index ca77c51..0000000
--- a/src/main/java/link/at17/mid/tushare/task/job/DailyUpdateDataJob.java
+++ /dev/null
@@ -1,93 +0,0 @@
-package link.at17.mid.tushare.task.job;
-
-import org.apache.commons.lang3.ArrayUtils;
-import java.util.Calendar;
-
-import org.apache.commons.lang3.time.DateUtils;
-import org.quartz.Job;
-import org.quartz.JobExecutionContext;
-import org.quartz.JobExecutionException;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Component;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-
-import link.at17.mid.tushare.dao.StockCalendarDao;
-import link.at17.mid.tushare.data.crawler.QueryWay;
-import link.at17.mid.tushare.data.crawler.tushare.TushareCrawler;
-import link.at17.mid.tushare.data.models.StockCalendar;
-import link.at17.mid.tushare.enums.StockHolderType;
-import link.at17.mid.tushare.enums.StockMarket;
-import link.at17.mid.tushare.system.util.SpringContextHolder;
-import lombok.extern.slf4j.Slf4j;
-
-@Component
-@Slf4j
-public class DailyUpdateDataJob implements Job {
-
-
- @Autowired
- TushareCrawler tushareCrawler;
-
- @Autowired
- StockCalendarDao stockCalendarDao;
-
- public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
-
-
- log.info("每日定时数据更新开始");
-
- try {
- String[] profile = SpringContextHolder.getApplicationContext().getEnvironment().getActiveProfiles();
- if (ArrayUtils.contains(profile, "remote")) {
- log.info("当前环境为远程调试环境,不参与每日数据更新");
- return;
- }
- }
- catch (Exception e) {
- log.error("获取当前 active profile 失败", e);
- return;
- }
-
-
- StockCalendar szLatestCal = stockCalendarDao.getLatest(StockMarket.SZ);
- StockCalendar shLatestCal = stockCalendarDao.getLatest(StockMarket.SH);
- if (szLatestCal == null || shLatestCal == null) {
- tushareCrawler.updateStockCalendar(StockMarket.SH, StockMarket.SZ);
- }
-
- tushareCrawler.updateStockCalendar(StockMarket.SH, StockMarket.SZ);
-
- // 查询当日是否是交易日
- Boolean todayIsOpen = stockCalendarDao.exists(new LambdaQueryWrapper()
- .eq(StockCalendar::getDate, DateUtils.truncate(Calendar.getInstance().getTime(), Calendar.DATE))
- .eq(StockCalendar::getIsOpen, true));
-
- if (!todayIsOpen) {
- log.info("当日非交易日,忽略更新");
- return;
- }
-
- int updateStockListRetry = 5;
- while (--updateStockListRetry > 0) {
- if (tushareCrawler.updateStockList()) {
- break;
- }
- }
- if (updateStockListRetry == 0) {
- // updateStockList failed
- log.warn("updateStockList 尝试更新失败,将在下一结算日后更新");
- }
- tushareCrawler.updateStockDaily(QueryWay.ByDateUpdate);
- tushareCrawler.updateDailyBasic(QueryWay.ByDateUpdate);
- tushareCrawler.updateStockAdjustTushare(QueryWay.ByDateUpdate);
- tushareCrawler.updateStockLimit(QueryWay.ByDateUpdate);
-
- tushareCrawler.updateThsDaily(QueryWay.ByDateUpdate);
- tushareCrawler.updateThsMember();
- tushareCrawler.updateStockHolder(StockHolderType.TOP10Float);
-
- log.info("每日定时更新数据完成");
- }
-
-}
diff --git a/src/main/java/link/at17/mid/tushare/task/job/UpdatePlanJob.java b/src/main/java/link/at17/mid/tushare/task/job/UpdatePlanJob.java
new file mode 100644
index 0000000..7f58d43
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/task/job/UpdatePlanJob.java
@@ -0,0 +1,61 @@
+package link.at17.mid.tushare.task.job;
+
+import java.lang.reflect.InvocationTargetException;
+
+import org.quartz.Job;
+import org.quartz.JobExecutionContext;
+import org.quartz.JobExecutionException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
+
+import link.at17.mid.tushare.data.models.UpdatePlan;
+import link.at17.mid.tushare.data.service.StockCalendarService;
+import link.at17.mid.tushare.service.UpdatePlanService;
+import lombok.extern.slf4j.Slf4j;
+
+@Component
+@Slf4j
+@Validated
+public class UpdatePlanJob implements Job {
+
+ @Autowired
+ private UpdatePlanService updatePlanService;
+
+ @Autowired
+ private StockCalendarService stockCalendarService;
+
+ @Override
+ public void execute(JobExecutionContext context) throws JobExecutionException {
+ UpdatePlan plan = (UpdatePlan) context.getMergedJobDataMap().get("updatePlan");
+
+ if (!plan.getEnabled()) {
+ log.info("任务 [{}]{} 未启用,忽略任务", plan.getId(), plan.getName());
+ return;
+ }
+
+ if (!plan.getValid()) {
+ log.info("任务 [{}]{} 不合法,忽略任务", plan.getId(), plan.getName());
+ return;
+ }
+
+ // 交易日检查
+ if (plan.getOpenDayCheck()) {
+ Boolean todayIsOpen = stockCalendarService.todayIsOpen();
+ if (!todayIsOpen) {
+ log.info("任务 [{}]{} 开启了交易日检查,当日非交易日,忽略任务", plan.getId(), plan.getName());
+ return;
+ }
+ }
+
+ try {
+ log.info("任务 [{}]{} 开始执行...", plan.getId(), plan.getName());
+ updatePlanService.execute(plan);
+ log.info("任务 [{}]{} 执行完毕", plan.getId(), plan.getName());
+ } catch (ClassNotFoundException | NoSuchMethodException | SecurityException | IllegalAccessException
+ | InvocationTargetException e) {
+ e.printStackTrace();
+ }
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/task/scheduler/CacheDailyEvictionScheduler.java b/src/main/java/link/at17/mid/tushare/task/scheduler/CacheDailyEvictionScheduler.java
index 9ac6f5b..ab5082d 100644
--- a/src/main/java/link/at17/mid/tushare/task/scheduler/CacheDailyEvictionScheduler.java
+++ b/src/main/java/link/at17/mid/tushare/task/scheduler/CacheDailyEvictionScheduler.java
@@ -7,10 +7,10 @@ import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import link.at17.mid.tushare.cache.CacheEvictionJob;
import link.at17.mid.tushare.task.TaskConstants;
-import link.at17.mid.tushare.task.TaskSchedulerFactory;
+import link.at17.mid.tushare.task.AutowireCapableJobFactory;
@Component
-public class CacheDailyEvictionScheduler extends TaskSchedulerFactory {
+public class CacheDailyEvictionScheduler extends AutowireCapableJobFactory {
@Resource(name="scheduler")
Scheduler scheduler;
diff --git a/src/main/java/link/at17/mid/tushare/task/scheduler/DailyUpdateDataScheduler.java b/src/main/java/link/at17/mid/tushare/task/scheduler/DailyUpdateDataScheduler.java
deleted file mode 100644
index e304e11..0000000
--- a/src/main/java/link/at17/mid/tushare/task/scheduler/DailyUpdateDataScheduler.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package link.at17.mid.tushare.task.scheduler;
-
-import org.quartz.*;
-import org.springframework.stereotype.Component;
-
-import jakarta.annotation.PostConstruct;
-import jakarta.annotation.Resource;
-import link.at17.mid.tushare.task.TaskSchedulerFactory;
-import link.at17.mid.tushare.task.job.DailyUpdateDataJob;
-
-@Component
-public class DailyUpdateDataScheduler extends TaskSchedulerFactory {
-
- @Resource(name="scheduler")
- private Scheduler scheduler;
-
- @PostConstruct
- public void startScheduler() throws SchedulerException {
- //创建调度器Schedule
- //创建JobDetail实例,并与HelloWordlJob类绑定
-
- JobDetail jobDetail = JobBuilder.newJob(DailyUpdateDataJob.class).withIdentity("cronJob").build();
- //创建触发器Trigger实例(每天3点执行)
- CronTrigger cronTrigger =
- TriggerBuilder.newTrigger().withIdentity("cronTrigger")
- .withSchedule(CronScheduleBuilder.cronSchedule("0 0 18 * * ? ")).build();
- //开始执行
- scheduler.scheduleJob(jobDetail, cronTrigger);
- scheduler.start();
- }
-}
\ No newline at end of file
diff --git a/src/main/java/link/at17/mid/tushare/web/controller/ManageController.java b/src/main/java/link/at17/mid/tushare/web/controller/ManageController.java
index 156b45b..43257d9 100644
--- a/src/main/java/link/at17/mid/tushare/web/controller/ManageController.java
+++ b/src/main/java/link/at17/mid/tushare/web/controller/ManageController.java
@@ -40,8 +40,16 @@ public class ManageController {
return "admin/manage/views/index.html";
}
+ @GetMapping("/manage/demo-index")
+ private String demoIndex() {
+ return "admin/manage/views/demo-index.html";
+ }
+
@GetMapping("/manage/{*routine}")
private String routine(@PathVariable String routine) {
+ routine = routine.replaceAll("\\/+", "/");
+ routine = routine.replaceAll("\\\\+", "/");
+ routine = routine.replaceFirst("\\/", "");
return "admin/manage/views/" + routine;
}
diff --git a/src/main/java/link/at17/mid/tushare/web/controller/UpdateMethodController.java b/src/main/java/link/at17/mid/tushare/web/controller/UpdateMethodController.java
new file mode 100644
index 0000000..11a3f8a
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/web/controller/UpdateMethodController.java
@@ -0,0 +1,52 @@
+package link.at17.mid.tushare.web.controller;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.validation.Validator;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import link.at17.mid.tushare.data.models.UpdateMethodInfo;
+import link.at17.mid.tushare.data.models.UpdatePlan;
+import link.at17.mid.tushare.dto.LayPageReq;
+import link.at17.mid.tushare.dto.LayPageResp;
+import link.at17.mid.tushare.service.UpdateMethodService;
+import link.at17.mid.tushare.service.UpdatePlanService;
+import lombok.extern.slf4j.Slf4j;
+
+@Controller
+@RequestMapping("/admin/manage/reviews/update-methods")
+@Slf4j
+public class UpdateMethodController extends BaseController {
+
+ @Autowired
+ Validator validator;
+
+ @Autowired
+ UpdateMethodService updateMethodService;
+
+ @Autowired
+ UpdatePlanService updatePlanService;
+
+ @GetMapping("/plan-list")
+ public String planList() {
+ return "admin/manage/reviews/plans/plan-list";
+ }
+
+ @GetMapping("/method-list-resp")
+ @ResponseBody
+ public Collection updateMethodInfoList() {
+ return updateMethodService.getPotentialUpdateMethodInfos();
+ }
+
+ @GetMapping("/list")
+ @ResponseBody
+ public LayPageResp> list(LayPageReq req) {
+ return new LayPageResp<>(updatePlanService.page(req));
+ }
+
+}
diff --git a/src/main/java/link/at17/mid/tushare/web/controller/UpdatePlanController.java b/src/main/java/link/at17/mid/tushare/web/controller/UpdatePlanController.java
new file mode 100644
index 0000000..40d6214
--- /dev/null
+++ b/src/main/java/link/at17/mid/tushare/web/controller/UpdatePlanController.java
@@ -0,0 +1,105 @@
+package link.at17.mid.tushare.web.controller;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.validation.Validator;
+import org.springframework.validation.annotation.Validated;
+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.ResponseBody;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
+import com.baomidou.mybatisplus.core.metadata.TableInfo;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+
+import link.at17.mid.tushare.data.models.UpdatePlan;
+import link.at17.mid.tushare.dto.LayPageReq;
+import link.at17.mid.tushare.dto.LayPageResp;
+import link.at17.mid.tushare.dto.R;
+import link.at17.mid.tushare.service.UpdateMethodService;
+import link.at17.mid.tushare.service.UpdatePlanService;
+import link.at17.mid.tushare.web.exception.RException;
+import lombok.extern.slf4j.Slf4j;
+
+@Controller
+@RequestMapping("/admin/manage/reviews/plans")
+@Slf4j
+@Validated
+public class UpdatePlanController extends BaseController {
+
+ @Autowired
+ Validator validator;
+
+ @Autowired
+ UpdateMethodService updateMethodService;
+
+ @Autowired
+ UpdatePlanService updatePlanService;
+
+ @GetMapping("/plan-list")
+ public String planList() {
+ return "admin/manage/reviews/plans/plan-list";
+ }
+
+ @GetMapping("/list")
+ @ResponseBody
+ public LayPageResp> list(LayPageReq req) {
+ return new LayPageResp<>(updatePlanService.page(req, new LambdaQueryWrapper().orderByAsc(UpdatePlan::getId)));
+ }
+
+ @GetMapping("/get")
+ @ResponseBody
+ public R> get(Integer id) {
+ UpdatePlan plan;
+ if (id == null) {
+ plan = new UpdatePlan();
+ return R.ok(plan);
+ }
+ plan = updatePlanService.getById(id);
+ return R.judge(plan != null, plan, "无法找到对应 ID 的 Plan");
+ }
+
+ @PostMapping("/save")
+ @ResponseBody
+ public R> save(@Validated @RequestBody UpdatePlan updatePlan) {
+ updatePlan.getMethods().forEach(method -> updateMethodService.fillUpdateMethodInfo(method));
+ updatePlan.setValid(true);
+ return R.judge(updatePlanService.saveOrUpdate(updatePlan));
+ }
+
+ @PostMapping("/updateBool")
+ @ResponseBody
+ public R> updateBool(Integer id, String name, Boolean value) {
+ if (!List.of("enabled", "openDayCheck").contains(name)) {
+ throw RException.badRequest("非法字段名" + name);
+ }
+ if (value == null) {
+ throw RException.badRequest("不允许空值");
+ }
+
+ TableInfo tableInfo = TableInfoHelper.getTableInfo(UpdatePlan.class);
+ String idField = tableInfo.getKeyColumn();
+ String dbField = tableInfo.getFieldList().stream()
+ .filter(f -> f.getProperty().equals(name))
+ .findFirst()
+ .map(TableFieldInfo::getColumn)
+ .orElse(null);
+ return R.judge(updatePlanService.update(new UpdateWrapper().eq(idField, id).set(dbField, value)));
+ }
+
+ @GetMapping("/execute")
+ @ResponseBody
+ public void execute(Integer id) throws ClassNotFoundException, NoSuchMethodException, SecurityException, IllegalAccessException, InvocationTargetException {
+ UpdatePlan updatePlan = updatePlanService.getById(id);
+ updatePlanService.execute(updatePlan);
+ }
+
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index cf30286..50157c3 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -27,10 +27,10 @@ mybatis-plus:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging.level:
- link.at17.mid.tushare: info
+ link.at17.mid.tushare: debug
link.at17.mid.tushare.test: debug
- org.springframework.security: debug
- org.springframework.security.web.access.intercept.RequestMatcherDelegatingAuthorizationManager: trace
+ # org.springframework.security: debug
+ # org.springframework.security.web.access.intercept.RequestMatcherDelegatingAuthorizationManager: trace
spring:
devtools:
@@ -50,7 +50,7 @@ spring:
profiles.active: local
session.timeout: 86400
thymeleaf:
- prefix: classpath:/webpage/
+ prefix: classpath:/templates/
suffix: .html
mode: HTML
encoding: UTF-8
diff --git a/src/main/resources/conf/system/system.json b/src/main/resources/conf/system/system.json
index 2f194a4..614c2b4 100644
--- a/src/main/resources/conf/system/system.json
+++ b/src/main/resources/conf/system/system.json
@@ -1,8 +1,8 @@
{
- "tushareToken" : "123",
- "proxyType" : "SOCKS",
+ "tushareToken" : "6f284d9246bad80c3eff946f3ecae8442072b1e60652785f66007509",
+ "proxyType" : "DIRECT",
"proxyHost" : "",
"proxyPort" : 1,
- "ignoreHttpsVerification" : true,
- "proxyUrl" : "socks5://:1"
+ "ignoreHttpsVerification" : false,
+ "proxyUrl" : null
}
\ No newline at end of file
diff --git a/src/main/resources/mappers/StockAdjust.xml b/src/main/resources/mappers/StockAdjust.xml
index 7db528f..c2a19ac 100644
--- a/src/main/resources/mappers/StockAdjust.xml
+++ b/src/main/resources/mappers/StockAdjust.xml
@@ -3,7 +3,7 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd" >
-
+
INSERT INTO stock_adjust_factor_tushare
(ts_code, trade_date, adj_factor)
VALUES
diff --git a/src/main/resources/mappers/StockCalendar.xml b/src/main/resources/mappers/StockCalendar.xml
index 1aa02c1..070cb1c 100644
--- a/src/main/resources/mappers/StockCalendar.xml
+++ b/src/main/resources/mappers/StockCalendar.xml
@@ -123,7 +123,7 @@
-