Compare commits

...

10 Commits

332 changed files with 36703 additions and 2160 deletions

2
.gitignore vendored
View File

@@ -31,3 +31,5 @@ build/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
/hs_err_pid**.log
/truffle.log

View File

@@ -62,7 +62,10 @@
```js ```js
// Define main function (script entry) // Define main function (script entry)
function main(config, profileName) { function main(config, profileName)
// 此处的 dypnsapi.aliyun... 是益盟调用手机号登录的接口
// 为了统一请求 IP 且不暴露真实 IP这部分需要 REJECT-DROP
config.rules.unshift('DOMAIN-SUFFIX,dypnsapi.aliyuncs.com,REJECT-DROP');
config.rules.unshift('DOMAIN-SUFFIX,dypnsapi.aliyun.com,REJECT-DROP'); config.rules.unshift('DOMAIN-SUFFIX,dypnsapi.aliyun.com,REJECT-DROP');
config.rules.unshift('DOMAIN-SUFFIX,emoney.cn,喵酥云'); config.rules.unshift('DOMAIN-SUFFIX,emoney.cn,喵酥云');
config.rules.unshift('DOMAIN-KEYWORD,emapp,喵酥云'); config.rules.unshift('DOMAIN-KEYWORD,emapp,喵酥云');

View File

@@ -1,10 +0,0 @@
{
"id" : "38",
"name" : "黄金通道强弱区",
"nameCode" : "10000800",
"data" : [ {
"title" : "黄金通道强弱区:",
"items" : [ "该指标仅适用于60分钟周期辅助判断短周期股价运行区间的强弱度具体详见高级课内容。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "42",
"name" : "PB",
"nameCode" : "10001500",
"data" : [ {
"title" : "PB:",
"items" : [ "PB估值是指市净率指持仓股票价格除以每股净资产的比率。", "PB分位是指该估值所处的历史分位数。", "注当估值不在合理范围时在图形中我们会将值调整到合理范围内。如当PB为负时其百分位我们定义为100%,即处于高估。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "41",
"name" : "PE",
"nameCode" : "10001600",
"data" : [ {
"title" : "PE:",
"items" : [ "PE估值是指市盈率指持仓股票价格除以每股收益(每股收益,EPS)的比率。", "PE百分位是指该估值所处的历史分位数。", "注当估值不在合理范围时在图形中我们会将值调整到合理范围内。如当PE为负时其百分位我们定义为100%,即处于高估。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "48",
"name" : "动态估值",
"nameCode" : "10001700",
"data" : [ {
"title" : "动态估值指标用法:",
"items" : [ "基于对未来业绩预测的动态估值,与公司实际市值对比,计算出估值状态。", "用法:", "1、公司实际市值/动态估值<0.8,为机会区间;", "2、公司实际市值/动态估值>1.25,为预警区间;", "3、0.8<=公司实际市值/动态估值<=1.25,为观察区间。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "49",
"name" : "解套空间",
"nameCode" : "10001800",
"data" : [ {
"title" : "解套空间:",
"items" : [ "体现了机构持股成本与当前股价的差距。解套空间为绿色时,表示机构持股成本高于当前股价,值越大表示机构被套程度越深;解套空间为红色时,表示机构持股成本低于当前股价,值越大表示机构获利比例越高;解套空间为黄色时,表示机构没有被套也没有获利;如果解套空间为黄色且在零轴持续一段时间,说明在此时间段内没有机构的持股数据。", "注机构包括公募基金、国家队、QFII和知名私募四类机构。", "机构持股成本由算法模型估算得出,仅供参考。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "50",
"name" : "机构认可度(个股)",
"nameCode" : "10001900",
"data" : [ {
"title" : "机构认可度(个股):",
"items" : [ "指基金公司国家队QFII和知名私募四类机构持有该股的流通市值占总市值的比例。该指标指按报告期更新实线表示该报告期数据已披露完整虚线表示该报告期数据尚未披露延续上一报告期指标值或数据未披露完整采用已披露数据计算指标值。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "57",
"name" : "活跃机构增减仓",
"nameCode" : "10002100",
"data" : [ {
"title" : "活跃机构增减仓:",
"items" : [ "指基金公司国家队QFII和知名私募四类机构累计增减仓的情况。指标柱为红色时数字为正代表该报告期内机构增仓红柱越高代表增仓幅度越大指标柱为绿色时数字为负代表该报告期内机构减仓绿柱越高代表减仓幅度越大。", "该指标指按报告期更新,实线表示该报告期数据已披露完整,虚线表示该报告期数据尚未披露延续上一报告期指标值或数据未披露完整采用已披露数据计算指标值。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "52",
"name" : "机构认可度(板块)",
"nameCode" : "10002200",
"data" : [ {
"title" : "机构认可度(板块):",
"items" : [ "指基金公司国家队QFII和知名私募四类机构持有该板块的流通市值占板块总市值的比例。该指标值按报告期更新实线表示该报告期数据已披露完整虚线表示该报告期数据尚未披露完整延续上一报告期指标值。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "53",
"name" : "机构持股覆盖率",
"nameCode" : "10002300",
"data" : [ {
"title" : "机构持股覆盖率:",
"items" : [ "指基金公司国家队QFII和知名私募四类机构所持有该板块成分股家数除以该板块成分股总家数。该指标值按报告期更新实线表示该报告期数据已披露完整虚线表示该报告期数据尚未披露完整延续上一报告期指标值。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "54",
"name" : "机构参与度(板块)",
"nameCode" : "10002400",
"data" : [ {
"title" : "机构参与度 (板块):",
"items" : [ "指基金公司国家队QFII和知名私募四类机构持有该板块成分股的机构家数。该指标值按报告期更新实线表示该报告期数据已披露完整虚线表示该报告期数据尚未披露完整延续上一报告期指标值。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "55",
"name" : "机构持股市值",
"nameCode" : "10002500",
"data" : [ {
"title" : "机构持股市值:",
"items" : [ "指报告期期末基金公司国家队QFII和知名私募这四类机构所持有的该板块流通市值。该指标值按报告期更新实线表示该报告期数据已披露完整虚线表示该报告期数据尚未披露完整延续上一报告期指标值。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "56",
"name" : "机构配置度",
"nameCode" : "10002600",
"data" : [ {
"title" : "机构配置度:",
"items" : [ "指基金公司国家队QFII和知名私募这些四类机构合计持有某个板块流通股市值占该板块总份额市值的比例。该指标值按报告期更新实线表示该报告期数据已披露完整虚线表示该报告期数据尚未披露完整延续上一报告期指标值。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "14",
"name" : "龙腾活跃",
"nameCode" : "10003001",
"data" : [ {
"title" : "龙腾活跃:",
"items" : [ "1. 海面线:股价走势趋向上趋于平淡和活跃变化的分水岭;", "2. 龙腾活跃处于海面线以下,走势表现处于相对平淡和疲弱,为不活跃状态;", "3. 龙腾活跃处于海面线上方,形态上趋向活跃,股价相对有望出现上涨走势,位置越高也就相对越活跃;", "4. 龙腾活跃线上穿海面线,走势形态上开始呈现活跃变化,可以顺势把握个股的潜在上涨机会;" ],
"image" : null
} ]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +0,0 @@
{
"id" : "15",
"name" : "深跌",
"nameCode" : "10003300",
"data" : [ {
"title" : "深跌:",
"items" : [ "深跌指标,主要体现股价形态上是否具备阶段性的超跌和处于相对的低位。指标主要由阶段下跌指标线,以及浅色、蓝色、深蓝色背景区域组成。", "阶段下跌指标线:体现阶段性下跌空间,和形态上相对回弹的对比,方向向下表明整体处于下跌趋向,反之表明整体处于回升或上涨趋向。", "当指标线向下进入到浅色背景区域,意味着走势形态上具备阶段性深跌,存在技术性反弹预期和可能。", "当指标线向下进入到蓝色背景区域,意味着阶段性超跌,反弹预期可能性明显。", "当指标线向下进入到深蓝色背景区域,意味着形态上具备阶段性严重超跌,底部反弹预期更强。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "16",
"name" : "强势区间",
"nameCode" : "10003400",
"data" : [ {
"title" : "强势区间:",
"items" : [ "强势区间指标,是龙腾四海指标的另一种指标呈现方式。", "强势区间指标,以黄颜色背景区域和红色背景区域,突出体现技术上形态上的强度。", "当龙腾四海线由下往上,进入到黄色背景区域,为技术上进入相对活跃和形态转强,股价相对更易出现上涨。", "当龙腾四海线由下往上,进入到红色背景区域,为技术上进入强势格局,股价容易出现短期强势上涨或加速。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "17",
"name" : "买卖频谱",
"nameCode" : "10003500",
"data" : [ {
"title" : "买卖频谱:",
"items" : [ "白线为买线,黄线为卖线。", "当白线向上穿越黄线为买入信号,此时柱差显示为红色系频谱。", "当白线向下击穿黄线为卖出信号,此时柱差显示威绿色系频谱。", "红色系频谱展开越充分,上升力度越强。", "绿色系频谱展开越充分,下跌力度越强。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "21",
"name" : "量王叠现",
"nameCode" : "10003600",
"data" : [ {
"title" : "量王叠现:",
"items" : [ "量王叠现指标是基于量能变化统计编制而成,一般适用于日线周期。", "该指标将量能情况标识为三种状态:", "1.普通量能", "2.条纹红柱代表阶段性量能较强劲", "3.紫色柱状代表当日放量至少为昨日一倍以上", "该指标可搭配其他指标使用以作为量能方面的考量依据例如红柱状态下发出B点等等" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "22",
"name" : "黄金回踩",
"nameCode" : "10003700",
"data" : [ {
"title" : "黄金回踩:",
"items" : [ "顺势而为,伏击个股主升浪", "当黄金眼信号触发后以股价作为主线股价回调并触碰MA30均线并之后股价重新站回MA30均线并且整个过程MA5和MA10任意一条都不能死叉MA30即触发黄金眼回踩不然就是黄金眼破坏。", "二买点黄金眼回踩触发后之后黄金眼未被破坏股价由S点转为B点B点即为第二买点。股价回调出S点暂时先出场等黄金眼回踩并转为B点", "加仓:黄金回踩触发后,股价一直处于红色持股状态,后续再出回踩信号,即为加仓点。", "卖出操盘线S点出发必须先行卖出且是最后的卖点随后等待再次买入信号。", "无效回踩股价出发黄金眼后出现回调股价未能触碰到30日均线即为黄金眼无效回踩。", "有效回踩后被破坏股价回调后并触碰MA30均线之后股价又会到MA30均线之上即形成黄金眼有效回踩但之后MA5或MA10任意一条死叉MA30即黄金眼破坏。即使之后股价再回到MA30之上也定义为黄金眼破坏。盘后确认", "优先交易环境: ", "30日均线上行个股优先黄金眼出发回踩后30日均线上行股票优先。", "绩优股优先:业绩有保证、前景看好的股票出现黄金眼后优先考虑。", "次新股优先:上市后爆炒回落首次出现符合条件的黄金眼回踩的个股优先考虑。", "多重信号优先:近期频繁出现黄金眼回踩,且后一个黄金眼回踩高于前一个黄金眼回踩的股票可优先考虑。", "避免交易环境:", "1. 避免年度已出现大幅上涨且已在高位的股票,出现翻倍行情的个股更高谨慎对待,避免高位站岗。", "2. 避免长期走势横盘整理的个股,该类个股因为区间窄幅波动,信号同样频繁,特点是信号发出后都没有收益,且在高点。", "3. 仙股、超级低价股、退市警示股以及基本面不佳的个股要规避。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "39",
"name" : "蹦极新生",
"nameCode" : "10003800",
"data" : [ {
"title" : "蹦极新生:",
"items" : [ "阶段性上涨趋势,股价出现短期快速回调,趋势顶底短期线异动下探,而后股价回升上涨。", "股价有短期洗盘休整后再度上涨的迹象" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "23",
"name" : "伏击活跃股",
"nameCode" : "10003900",
"data" : [ {
"title" : "伏击活跃股:",
"items" : [ "强势活跃表现,伏击短期回落,顺势短期价差。", "1. 成交量集中放量(大资金青睐)", "2. 换手率高(股性活跃)", "3. 对比大盘指数来看,股价的波动性大(股性活跃)", "4. 大单比率高,资金流向呈上升趋势(主力正在介入)" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "27",
"name" : "锅底右侧",
"nameCode" : "10004000",
"data" : [ {
"title" : "锅底右侧:",
"items" : [ "技术上下跌充分,经历阶段性筑底后,股价回升上涨,股价有望进入右侧反弹上涨趋向" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "28",
"name" : "大阳起势",
"nameCode" : "10004100",
"data" : [ {
"title" : "大阳起势:",
"items" : [ "经历过阶段性上涨,技术上已脱离底部,股价经过短期震荡休整后,中大阳线强势上涨,逾越休整形态或重要均线位置" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "32",
"name" : "龙腾长波",
"nameCode" : "10004200",
"data" : [ {
"title" : "龙腾长波指标说明:",
"items" : [ "趋势性上涨形态,股价出现阶段性震荡休整或调整,而后再次起涨走强" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "34",
"name" : "冰谷火焰",
"nameCode" : "10004300",
"data" : [ {
"title" : "冰谷火焰指标说明:",
"items" : [ "股价经历中长期的充分下跌K线形态上止跌回升或尖冲下影有阶段性左侧止跌迹象有望逐步进入筑底或底部回升。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "35",
"name" : "资金潜龙",
"nameCode" : "10004400",
"data" : [ {
"title" : "资金潜龙指标说明:",
"items" : [ "前期历经阶段性的下跌,技术上相对低位,阶段内大单流入偏多或资金做多回流行为迹象。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "强龙起势",
"nameCode" : "10004702",
"data" : [ {
"title" : "强龙起势指标:",
"items" : [ "股价的强势上涨,背后往往离不开多种因素的综合影响,首先,强势的外在最直接表现就是量价的配合反应。", "其次,股价预期上涨途中,要尽可能少压力,股价背后的多空筹码最好要经历足够的消化和收集,上方没有明显的抛压,才利于股价的走强。", "再者,股价持续上涨和走强的潜力,往往需要有形态上的向好和偏强为基础。另外,股价背后有主力资金的做多支持,往往更具走强和持续走强的潜质。", "强龙起势,就是基于上述因素,分别从量价、筹码、形态、资金等不同维度,以不同颜色色块的方式体现股价涨跌背后是否具备相应的走强因素。并辅助以强风口、火线的入选来加强对个股的判断", "方便综合研判股价涨跌背后是否具备强势的潜质。", "量价:有色块则以为这个股短期趋势趋于向好,量能相对活跃。若量价不满足,则色块为空。", "筹码:股价背后筹码有过较充分的消化和收集,短期上方抛压不明显。若筹码不满足,则色块为空。", "形态:股价形态上有短期突破的表现,或者指标形态上短期走强,整体处于相对偏强。若形态不满足,则色块为空。", "资金:当天主力有一定力度的做多支持,短期或阶段性有资金做多行为支持。若资金不满足,则色块为空。", "强风口近15个交易日内入选风口全景/风口主线的题材,入选后显示蓝色风口图。", "火线近15日入选火线题材入选后显示火焰标志", "指标上方针对同时满足量价、筹码、形态、资金四个要素的数量进行相应的数字呈现1为满足一种强势因子2为满足其中两种强势因子以此类推。对于上方的数字符合操盘线红色对应上方的数字显示为红色对于不符合操盘线红色上方均显示为简单的纯数字。", "对于股价背后的强势潜在同时满足上述因素的数量越多往往意味着个股短期更具强势潜质因此对于同时满足3和4种因素的色块代表股价有起势信号。", "多个连续黄色信号仅显示第一个首3和首4。若4的黄色信号在前代表起势信号更明确随后跟随的3不在显示黄色信号。", "强龙起势增加了三步擒龙触发条件的显示,若满足三步擒龙紫色信号,代表股价有转折趋势,可提前关注。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "强势风口",
"nameCode" : "10004900",
"data" : [ {
"title" : "强势风口指标说明:",
"items" : [ "强势风口由三线强势,强风口、火线题材三大条件组成,旨在帮助用户发现超强风口,叠加强势周期和强势股要素共同使用,可提升操作胜率。", "1、三线强势利用不同周期的共振挖掘强势板块入选后显示空心黄色方格", "2、强风口近15个交易日内入选风口全景/风口主线的题材,入选后显示蓝色风口图标", "3、火线题材近15个交易日内入选火线题材的概念入选后显示红色火苗", "4、三线强势+强风口+火线题材三个指标信号共同出现的时候,显示红色背景,代表入选三个风口,属于强势风口。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "29",
"name" : "锅底精选",
"nameCode" : "10005301",
"data" : [ {
"title" : "主题博弈-锅底精选:",
"items" : [ "《主题博弈-锅底精选》指标是一套发现底部信号的操作指标。分为偏左侧的“拐点”信号与右侧的“锅底”信号。具体用法参考主题博弈相关教程。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "30",
"name" : "盘龙活跃区2.0",
"nameCode" : "10005400",
"data" : [ {
"title" : "蓄势盘龙-盘龙活跃区2.0",
"items" : [ "【盘龙活跃区2.0】是一套捕捉个股右侧主升浪操作指标,具体用法参考蓄势盘龙高级课教程。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "31",
"name" : "量王精选",
"nameCode" : "10005500",
"data" : [ {
"title" : "量王精选:",
"items" : [ "红色实心柱子是量王,黄色实心柱子是天量;量王出现说明近阶段的量能达到了一定的程度,呈现交易相对活跃状态。具体用法参考量王精选相关教程。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "36",
"name" : "均线三部曲-中期四线",
"nameCode" : "10005700",
"data" : [ {
"title" : "【均线三部曲--中期四线】指标:",
"items" : [ "水平虚线-对应上方白色数字为均线粘合度的参考值主要用来衡量粘合程度是否达4线参考数值1.05<br />\n绿色柱状-对应上方绿色数字4线粘合表示4条均线粘合程度当4线粘合值<1.05时会显示绿色柱状上方绿色数值越小说明4条均线越粘合<br />\n黄色柱状-对应上方黄色数字表示4条均线方向一致向上上方黄色数值表示第几天出现共振<br />\n红色柱状-对应上方红色数字表示4条均线从小到大依次排列上方红色数值表示第几天出现多头<br />\nPS4线-MA 参数为5、10、20、60柱子在水平虚线上下可以不用过多关注主要关注颜色。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "37",
"name" : "黄金通道",
"nameCode" : "10005800",
"data" : [ {
"title" : "黄金通道:",
"items" : [ "该指标仅适用于60分钟周期适用于强势个股的交易辅助具体详见高级课内容。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "44",
"name" : "龙行趋势线",
"nameCode" : "10006000",
"data" : [ {
"title" : "龙行趋势线:",
"items" : [ "《龙行趋势线》指标用图形和数值体现长期和短期两个级别的趋势变化,红绿柱状描述长期趋势变化,红绿带状描述短期趋势变化", "多空:正值代表当前为多头环境,负值代表当前为空头环境;", "空间:当前价格对比历史最高价格的比值;", "长多:长期多头趋势维持的天数;", "长空:长期空头趋势维持的天数;", "短多:短期多头趋势维持的天数;", "短空:短期空头趋势维持的天数;" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "47",
"name" : "龙抬头",
"nameCode" : "10006100",
"data" : [ {
"title" : "龙抬头指标说明:",
"items" : [ "符合蓄势盘龙形态,并且当前资金活跃,处于右侧拐头、安全入口的个股,具体用法详见蓄势盘龙高级课,建议结合指数及风口节奏进行使用。" ],
"image" : null
} ]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +0,0 @@
{
"id" : "18",
"name" : "北上资金买卖净额",
"nameCode" : "10006900",
"data" : [ {
"title" : "北上资金买卖净额:",
"items" : [ "根据港交所数据深度计算的每日净流入、净流出。数据单位为万元。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "19",
"name" : "北上资金持股占比",
"nameCode" : "10007000",
"data" : [ {
"title" : "北上资金持股占比:",
"items" : [ "港资所持股票数量占流通A股的比例。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "13",
"name" : "ZLJC",
"nameCode" : "10007700",
"data" : [ {
"title" : "主力进出:",
"items" : [ "1. 若多方主力向上穿越空方主力,则为买入信号。", "2. 若多方主力向下击破空方主力,则为卖出信号。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "46",
"name" : "DKBY",
"nameCode" : "10008500",
"data" : [ {
"title" : "DKBY指标说明:",
"items" : [ "多空博弈", "1.当“多方”线向上与“空方”线“金叉”时为买点。", "2.当“多方”线向下与“空方”线“死叉”时为卖点。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "DDI",
"nameCode" : "10008600",
"data" : [ {
"title" : "DDI:",
"items" : [ "分析DDI柱状线由红变绿(正变负),卖出信号;由绿变红,买入信号。" ],
"image" : null
} ]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +0,0 @@
{
"id" : "26",
"name" : "BDWD",
"nameCode" : "10009900",
"data" : [ {
"title" : "波段无敌:",
"items" : [ "1. 取值范围:-50+50。", "2. 0轴以下为空方优势区域0轴以上为多头优势区。", "3. 若BD值等于-50且横盘坚决持币观望。", "4. 若BD值等于+50且横盘坚决持股待涨且往往会涨势加速。", "5. 股价回调不破+30继续持股待涨属于正常调整。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "12",
"name" : "融资融券",
"nameCode" : "10010100",
"data" : [ {
"title" : "融资融券",
"items" : [ "“融资融券”又称“证券信用交易”,包括券商对投资者的融资、融券和金融机构对券商的融资、融券。", "融资是借钱买证券,证券公司借款给客户购买证券,客户到期偿还本息,客户向证券公司融资买进证券称为“买空”;", "融券是借证券来卖,然后以证券归还,证券公司出借证券给客户出售,客户到期返还相同种类和数量的证券并支付利息,客户向证券公司融券卖出称为“卖空”。", "融资余额:每天融资买进股票额与偿还融资额间的差额", "融券余额:每天融券卖出股票额与偿还融券间的差额", "融资余额增加时,表示投资者心态偏向买方,市场人气旺盛,属强势市场;反之则属于弱势市场。", "融券余额增加,表示市场趋向卖方市场;反之则趋向买方。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : "33",
"name" : "CMFX",
"nameCode" : "10010200",
"data" : [ {
"title" : "CMFX指标说明",
"items" : [ "筹码分析:分析市场筹码流动情况,掌握多空对战实力。", "1.白线代表市场锁定筹码,多为主力控盘筹码,数值越大说明主力控盘锁定筹码越多,上涨概率越大。", "2.黄线代表市场浮动筹码,多为散户流动筹码,数值越大说明散户持有浮动筹码越多,下跌概率越大。", "3.柱差反映市场筹码控盘能力,反映主力控盘力度,预示后市上扬概率及幅度,红柱越大,说明主力控盘度越高,上涨概率越高,反之则下跌概率越高。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "黄金坑",
"nameCode" : "10011400",
"data" : [ {
"title" : "黄金坑指标说明:",
"items" : [ "深度,图中金黄色区域,表明股票跌幅深度,深度越大,反弹力度会越大。", "估值,即估值星级,黄色上方的橙色部分,越大表示估值相对越低。", "弹性,橘红色部分,弹性越大,随市场反弹速度越快。", "纯度,大红色部分,纯度越高,表明市场黄金坑股票越多,市场整体反弹可能越大。", "用法:", "先找黄金坑,再看纯度,然后关注估值和弹性,整体是这些值越大越好。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "顺势强度",
"nameCode" : "10011500",
"data" : [ {
"title" : "顺势强度指标说明:",
"items" : [ "表示上涨趋势强度。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "偏离度",
"nameCode" : "10012200",
"data" : [ {
"title" : "偏离度:",
"items" : [ "表示标的价格偏离中期线的程度:", "当价格处于中期线上方时,偏离越远,风险越大;", "当价格处于中期线下方时,偏离越远,机会越大。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "步步高指标",
"nameCode" : "10012400",
"data" : [ {
"title" : "步步高指标:",
"items" : [ "价格的涨跌,是股价表现强弱的最直接体现,量能的放大和支持,往往是股价上涨的助推,体现的是股价背后交投的活跃性,因此,通过观察股价短期或阶段性的活跃性表现和变化,往往有助于分析判断股价是否具备上涨潜力和延续性。", "步步高,就是基于阶段性量价的起伏表现,相对中长期量价形态的变化,重心和相对背离等综合因素分析运算,指标化的揭示量价活跃性表现和变动趋向。实战中可以单独或结合其他指标,来辅助操盘线进行相应的分析判断。", "指标值范围从极值0到极值100。海面线作为活跃性变化的低位分水岭。海面线以下意味着活跃性偏弱相对不利于股价的上涨。活跃值线体向上意味着量价活跃性相对提升反之亦然。活跃值越高意味着量价活跃度越热反之越冷。", "当股价经历中长期的充分下跌后企稳回升,若活跃值由海面线以下转折向上,乃至逾越海面线,意味着里价活跃性的复苏,指标显示紫色柱线,辅助操盘线可低位稳操作。", "当活跃复苏后量价热度进一步提升活跃值线随之向上逾越30指标在紫色柱线上显示黄色意味着量价活跃性进入到中位缓升区辅助操盘线可中继顺势。", "进入缓升区后当量价热度继续升高活跃值线随之向上逾越50指标柱线上方显示橙色意味着量价活跃性进入到快升区量价热度相对较高辅助操盘线可把握潜在活跃性机会(活跃快升区忌恋战,活跃热度越高,越需要注意可能的量价起伏变化)。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "控盘资金(原短线主力)",
"nameCode" : "10012500",
"data" : [ {
"title" : "控盘资金(原短线主力)指标说明:",
"items" : [ "控盘资金指标反应的是具有雄厚资金的参与者(即控盘资金)的买入、卖出情况。这类资金的流入流出,对股票走势影响较大。", "红色代表买方占优,表示买入资金大于卖出资金。", "绿色代表卖出占优,表示卖出资金大于买入资金。", "数值大小反映了控盘资金流入流出的量的多少,越大越强烈。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "三步擒龙(新)",
"nameCode" : "10012600",
"data" : [ {
"title" : "三步擒龙(新)指标说明:",
"items" : [ "研判股价上涨和持续性上涨的潜力,往往离不开量价、趋势、形态等方面的考量。股价短期是否具备上涨的潜力,首先其短期趋势要处于预期向好的节奏内。其次股价走势形态上要趋于活跃和偏强,背后多空资金维度,多方需要占据相对的优势。再者股价上涨背后需要量能维度的交投活跃性配合。这也就是三步擒龙的基本考量,分别从趋势、资金和活跃等三个维度,以不同颜色的色块进行呈现(实心色块为该因素符合,反之空心为不符合)。", "趋势即短期趋势趋于向好日线操盘线B点或处于红色阶段。", "资金,即前期有过异动上涨,且控盘资金净流入占优。", " 以上三者均具备,则为三步擒龙的基本要求符合,意味个股短期走势上偏强,量价相对活跃。", "当然,对于个股股价的涨跌,背后若有市场热点或板块共性的支持,其短期往往更具上涨或活跃的潜力。因此指标下方融入了\"风口\"是否具备的辅助因素参考。若个股所属板块,当日符合火线题材或风口、强风口的,则指标下方显示\"火\",反之则为空。", "与此同时,为了方便对比和查看,指标上方显示了符合下方不同维度(趋势、活跃、资金)的数量1为符合其中一个要素2为符合其中两个维度3为符合三个。", "另外,对于趋势、活跃、资金等三个不同维度中,若当天符合趋势(操盘线)要求,则数字为红色背景;", "紫色信号:意味着短期有转折变强的迹象", "紫2前一天符合条件数0当天符合条件数2且操盘线红色前一天符合条件1或2但不符合操盘线红色当天符合条件数2。", "紫3前一天符合条件数0或1当天符合条件数3且操盘线红色前一天符合条件1或2但不符合操盘线红色当天符合条件数3。", "结合股价形态位置和特征,比如底部转折、中继性上涨或走强、形态上的突破走强等,相对更具参考性。" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "市盈率(PETTM)",
"nameCode" : "10012700",
"data" : [ {
"title" : "市盈率(PETTM)指标说明:",
"items" : [ "PETTM是个股最新总市值与近一年TTM归母净利润的比值反映的是股票估值的大小机会值和危险值分别表示近五年PETTM的30%分位值和70%分位值;", "PETTM越大 说明股票估值越贵, 高于危险值时,股票进入高估区间;", "PETTM越小 说明股票估值越便宜, 低于机会值时,股票进入低估区间;", "PETTM等于0 说明公司近一年TTM归母净利润亏损" ],
"image" : null
} ]
}

View File

@@ -1,10 +0,0 @@
{
"id" : null,
"name" : "量能识别",
"nameCode" : "10013300",
"data" : [ {
"title" : "量能识别指标说明:",
"items" : [ "量能识别指标用于帮助用户快速识别成交量的关键变化,结合股价走势和操盘线,可更精准地做出投资决策。指标包括三倍、倍量、放量、半量和地量,适用于不同市场行情。", "黄色量柱(三倍):当前成交量是前一日的三倍及以上。在上涨行情中,是主力大举买入的信号,股价有望加速上涨;在下跌行情中,可能是主力恐慌性抛售,股价面临大幅下跌风险。需高度重视并结合操盘线分析。", "橙色量柱(倍量):当前成交量是前一日的两倍。在上涨行情中,预示股价有望继续上扬;在下跌行情中,可能是恐慌性抛售或主力出逃的信号。需结合操盘线判断。", "紫色量柱(放量):股价上涨时成交量同步放大。表明上涨动力充足,是股价上涨的有力支撑信号。但若出现在高位,也可能是主力拉高出货,需结合操盘线判断。", "绿色量柱(半量):成交量较前一阶段减少一倍或以上。在上升趋势中,可能暗示资金跟进不足,趋势难持续;在下降趋势中,可能预示市场底部即将形成。", "灰色量柱(地量):成交量处于一段时间的历史低位。表明当前交易并不活跃,买卖双方观望情绪浓厚。可等更明确的市场信号出现后再判断。", "不同个股的量能表现有差异,大盘蓝筹股量能相对稳定,小盘股或题材股量能波动大。投资者需结合个股特性使用该指标。" ],
"image" : null
} ]
}

View File

@@ -1,6 +0,0 @@
{
"id" : "52",
"name" : "ATR",
"code" : "10010600",
"descriptions" : [ "算法今日振幅、今日最高与昨收差价、今日最低与昨收差价中的最大值为真实波幅求真实波幅的N日移动平均", "参数N为天数一般取14" ]
}

View File

@@ -1,5 +0,0 @@
{
"emoneyLoginFormDataList" : null,
"selectedId" : "57de6ca2423e0f64d8626477e1f8a46b",
"isRandom" : false
}

View File

@@ -1,18 +0,0 @@
{
"isAnonymous" : true,
"username" : "emy1730978",
"password" : "ubVa0vNmD+JJC4171eLYUw==",
"authorization" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6IkpXVCJ9.eyJ1dWQiOjEwMTQ2MzA5OTgsInVpZCI6Mjg2MTMyNDksImRpZCI6IjU2ZTFhNThiYmYxMjJiOTMyMjBhYzBkOThhMmQzZmU3IiwidHlwIjo0LCJhY2MiOiI1NmUxYTU4YmJmMTIyYjkzMjIwYWMwZDk4YTJkM2ZlNyIsInN3dCI6MSwibGd0IjoxNzQ3NDQyMzQzNTEzLCJuYmYiOjE3NDc0NDIzNDMsImV4cCI6MTc0OTE3MDM0MywiaWF0IjoxNzQ3NDQyMzQzfQ.QRcKBbTiE6EuIY-5wl_CVXUYVnzrQIy5LYVAkpfZLWM",
"uid" : 28613249,
"androidId" : "132b692fe032add3",
"androidVersion" : "9",
"androidSdkLevel" : "28",
"softwareType" : "Mobile",
"okHttpUserAgent" : "okhttp/3.12.2",
"deviceName" : "SM-J730F",
"fingerprint" : "samsung/j7y17ltexx/j7y17lte:9/PPR1.180610.011/J730FXWU8CUG1:user/release-keys",
"buildId" : "PPR1.180610.011",
"chromeVersion" : "117.0.5938.141",
"emoneyVersion" : "5.8.1",
"emappViewMode" : "1"
}

15
pom.xml
View File

@@ -9,7 +9,7 @@
<version>3.3.0</version> <version>3.3.0</version>
<relativePath /> <!-- lookup parent from repository --> <relativePath /> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>com.littlesweetdog.quant</groupId> <groupId>quant.rich.emoney</groupId>
<artifactId>emo-grab</artifactId> <artifactId>emo-grab</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>EmoGrab</name> <name>EmoGrab</name>
@@ -17,9 +17,9 @@
<properties> <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>18</java.version> <java.version>22</java.version>
<maven.compiler.source>18</maven.compiler.source> <maven.compiler.source>22</maven.compiler.source>
<maven.compiler.target>18</maven.compiler.target> <maven.compiler.target>22</maven.compiler.target>
</properties> </properties>
<dependencies> <dependencies>
<dependency> <dependency>
@@ -171,13 +171,6 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- https://mvnrepository.com/artifact/com.microsoft.playwright/playwright -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.51.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.apache.commons</groupId> <groupId>org.apache.commons</groupId>
<artifactId>commons-text</artifactId> <artifactId>commons-text</artifactId>

View File

@@ -13,7 +13,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
public class EmoneyAutoApplication { public class EmoneyAutoApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(EmoneyAutoApplication.class, args); SpringApplication app = new SpringApplication(EmoneyAutoApplication.class);
app.run(args);
} }
} }

View File

@@ -0,0 +1,19 @@
package quant.rich.emoney.annotation;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
/**
* 注解在方法上以获取对 EmoneyProtocol 的额外操作
*/
@Documented
@Retention(RUNTIME)
@Target(METHOD)
public @interface ResponseDecodeExtension {
String protocolId();
int order() default -1;
}

View File

@@ -17,11 +17,12 @@ import okhttp3.MediaType;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.RequestBody; import okhttp3.RequestBody;
import okhttp3.Response; import okhttp3.Response;
import quant.rich.emoney.entity.config.EmoneyRequestConfig; import quant.rich.emoney.entity.sqlite.RequestInfo;
import quant.rich.emoney.exception.EmoneyDecodeException; import quant.rich.emoney.exception.EmoneyDecodeException;
import quant.rich.emoney.exception.EmoneyIllegalRequestParamException; import quant.rich.emoney.exception.EmoneyIllegalRequestParamException;
import quant.rich.emoney.exception.EmoneyRequestException; import quant.rich.emoney.exception.EmoneyRequestException;
import quant.rich.emoney.exception.EmoneyResponseException; import quant.rich.emoney.exception.EmoneyResponseException;
import quant.rich.emoney.service.sqlite.RequestInfoService;
import quant.rich.emoney.util.EncryptUtils; import quant.rich.emoney.util.EncryptUtils;
import quant.rich.emoney.util.SpringContextHolder; import quant.rich.emoney.util.SpringContextHolder;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
@@ -30,10 +31,22 @@ import okhttp3.OkHttpClient;
* 益盟操盘手基本请求客户端,提供基本功能 * 益盟操盘手基本请求客户端,提供基本功能
* <p><b>请求头顺序</b></p> * <p><b>请求头顺序</b></p>
* <p> * <p>
* X-Protocol-Id > X-Request-Id > EM-Sign > Authorization > * <ul>
* X-Android-Agent > Emapp-ViewMode > Content-Type > Content-Length > * <li>X-Protocol-Id</li>
* Host > Connection: "Keep-Alive" > Accept-Encoding: "gzip" > User-Agent</p> * <li>X-Request-Id</li>
* <p>从 X-Protocol-Id 到 Emapp-ViewMode由本例添加剩余为 okhttp 默认添加, * <li>EM-Sign</li>
* <li>Authorization</li>
* <li>X-Android-Agent</li>
* <li>Emapp-ViewMode</li>
* <li>Content-Type</li>
* <li>Content-Length</li>
* <li>Host</li>
* <li>Connection: "Keep-Alive"</li>
* <li>Accept-Encoding: "gzip"</li>
* <li>User-Agent</li>
* </ul>
* </p>
* <p>从 X-Protocol-Id 到 Emapp-ViewMode 由本例添加,剩余为 okhttp 默认添加,
* User-Agent 由 ByteBuddy 重写 header 方法控制添加</p> * User-Agent 由 ByteBuddy 重写 header 方法控制添加</p>
* @see quant.rich.emoney.patch.okhttp.PatchOkHttp * @see quant.rich.emoney.patch.okhttp.PatchOkHttp
*/ */
@@ -43,33 +56,62 @@ import okhttp3.OkHttpClient;
public class EmoneyClient implements Cloneable { public class EmoneyClient implements Cloneable {
private static final String MBS_URL = "https://mbs.emoney.cn/"; 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 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 RELOGIN_URL = "https://emapp.emoney.cn/user/auth/ReLogin";
private static final String LOGIN_X_PROTOCOL_ID = "user%2Fauth%2Flogin"; private static final String LOGIN_X_PROTOCOL_ID = "user%2Fauth%2Flogin";
private static final String RELOGIN_X_PROTOCOL_ID = "user%2Fauth%2FReLogin"; private static final String RELOGIN_X_PROTOCOL_ID = "user%2Fauth%2FReLogin";
private static volatile EmoneyRequestConfig emoneyRequestConfig; private static volatile RequestInfoService requestInfoService;
private static EmoneyRequestConfig getEmoneyRequestConfig() { /**
if (emoneyRequestConfig == null) { * 根据 protocolId 返回 URL
* @param protocolId
* @return
*/
private static String getUrlByProtocolId(Serializable protocolId) {
if (protocolId instanceof Integer intProtocolId) {
switch (intProtocolId) {
case 9400: return STRATEGY_URL;
default: return MBS_URL;
}
}
else if (protocolId instanceof String strProtocolId) {
switch (strProtocolId) {
case LOGIN_X_PROTOCOL_ID: return LOGIN_URL;
case RELOGIN_X_PROTOCOL_ID: return RELOGIN_URL;
default: return null;
}
}
return null;
}
/**
* 从 Spring 上下文中获取载入的请求配置
* @return
*/
private static RequestInfo getDefaultRequestInfo() {
if (requestInfoService == null) {
synchronized (EmoneyClient.class) { synchronized (EmoneyClient.class) {
if (emoneyRequestConfig == null) { requestInfoService = SpringContextHolder.getBean(RequestInfoService.class);
emoneyRequestConfig = SpringContextHolder.getBean(EmoneyRequestConfig.class);
} }
} }
if (requestInfoService == null) {
log.warn("获取 RequestInfoService 实例失败");
return null;
} }
return emoneyRequestConfig; return requestInfoService.getDefaultRequestInfo();
} }
private EmoneyClient() {} private EmoneyClient() {}
/** /**
* 根据系统配置自动选择登录方式 * 根据系统配置自动选择登录方式,即匿名或不匿名
* @return * @return
* @see EmoneyRequestConfig * @see RequestInfo
*/ */
public static Boolean loginWithManaged() { public static Boolean loginWithManaged() {
if (getEmoneyRequestConfig().getIsAnonymous()) { if (getDefaultRequestInfo().isAnonymous()) {
return loginWithAnonymous(); return loginWithAnonymous();
} }
else { else {
@@ -85,7 +127,7 @@ public class EmoneyClient implements Cloneable {
*/ */
@Deprecated @Deprecated
public static Boolean loginWithUsernamePassword() { public static Boolean loginWithUsernamePassword() {
ObjectNode formObject = getEmoneyRequestConfig().getUsernamePasswordLoginObject(); ObjectNode formObject = getDefaultRequestInfo().getUsernamePasswordLoginObject();
return login(formObject); return login(formObject);
} }
@@ -98,7 +140,7 @@ public class EmoneyClient implements Cloneable {
*/ */
@Deprecated @Deprecated
public static Boolean loginWithUsernamePassword(String username, String password) { public static Boolean loginWithUsernamePassword(String username, String password) {
ObjectNode formObject = getEmoneyRequestConfig().getUsernamePasswordLoginObject(username, password); ObjectNode formObject = getDefaultRequestInfo().getUsernamePasswordLoginObject(username, password);
return login(formObject); return login(formObject);
} }
@@ -109,7 +151,7 @@ public class EmoneyClient implements Cloneable {
*/ */
@Deprecated @Deprecated
public static Boolean loginWithAnonymous() { public static Boolean loginWithAnonymous() {
ObjectNode formObject = getEmoneyRequestConfig().getAnonymousLoginObject(); ObjectNode formObject = getDefaultRequestInfo().getAnonymousLoginObject();
return login(formObject); return login(formObject);
} }
@@ -118,8 +160,8 @@ public class EmoneyClient implements Cloneable {
* @return * @return
*/ */
public static Boolean relogin() { public static Boolean relogin() {
EmoneyRequestConfig emoneyRequestConfig = getEmoneyRequestConfig(); RequestInfo requestInfo = getDefaultRequestInfo();
ObjectNode reloginObject = emoneyRequestConfig.getReloginObject(); ObjectNode reloginObject = requestInfo.getReloginObject();
if (reloginObject == null) { if (reloginObject == null) {
// 无登录信息,直接触发登录 // 无登录信息,直接触发登录
return loginWithManaged(); return loginWithManaged();
@@ -139,8 +181,8 @@ public class EmoneyClient implements Cloneable {
.header("X-Request-Id", "1") .header("X-Request-Id", "1")
.header("EM-Sign", EncryptUtils.getEMSign(content, "POST", RELOGIN_X_PROTOCOL_ID)) .header("EM-Sign", EncryptUtils.getEMSign(content, "POST", RELOGIN_X_PROTOCOL_ID))
.header("Authorization", token) .header("Authorization", token)
.header("X-Android-Agent", emoneyRequestConfig.getXAndroidAgent()) .header("X-Android-Agent", requestInfo.getXAndroidAgent())
.header("Emapp-ViewMode", emoneyRequestConfig.getEmappViewMode()); .header("Emapp-ViewMode", requestInfo.getEmappViewMode());
Request request = requestBuilder.build(); Request request = requestBuilder.build();
@@ -149,7 +191,7 @@ public class EmoneyClient implements Cloneable {
if (response.code() != 200) { if (response.code() != 200) {
// 不是 200重新登录 // 不是 200重新登录
log.debug("ReLogin 重登录验证返回状态码 {}, 触发登录", response.code()); log.debug("ReLogin 重登录验证返回状态码 {}, 需要重新登录", response.code());
return loginWithManaged(); return loginWithManaged();
} }
@@ -186,7 +228,7 @@ public class EmoneyClient implements Cloneable {
private static Boolean login(ObjectNode formObject) { private static Boolean login(ObjectNode formObject) {
try { try {
//OkHttpClient okHttpClient = new OkHttpClient(); //OkHttpClient okHttpClient = new OkHttpClient();
EmoneyRequestConfig emoneyRequestConfig = getEmoneyRequestConfig(); RequestInfo requestInfo = getDefaultRequestInfo();
OkHttpClient okHttpClient = OkHttpClientProvider.getInstance(); OkHttpClient okHttpClient = OkHttpClientProvider.getInstance();
MediaType type = MediaType.parse("application/json"); MediaType type = MediaType.parse("application/json");
//type.charset(StandardCharsets.UTF_8); //type.charset(StandardCharsets.UTF_8);
@@ -206,9 +248,9 @@ public class EmoneyClient implements Cloneable {
.header("X-Request-Id", "null") .header("X-Request-Id", "null")
.header("EM-Sign", EncryptUtils.getEMSign(content, "POST", LOGIN_X_PROTOCOL_ID)) .header("EM-Sign", EncryptUtils.getEMSign(content, "POST", LOGIN_X_PROTOCOL_ID))
.header("Authorization", "") .header("Authorization", "")
.header("X-Android-Agent", emoneyRequestConfig.getXAndroidAgent()) .header("X-Android-Agent", requestInfo.getXAndroidAgent())
.header("Emapp-ViewMode", emoneyRequestConfig.getEmappViewMode()); .header("Emapp-ViewMode", requestInfo.getEmappViewMode());
//.header("User-Agent", emoneyRequestConfig.getOkHttpUserAgent()) //.header("User-Agent", requestInfo.getOkHttpUserAgent())
Request request = requestBuilder.build(); Request request = requestBuilder.build();
@@ -220,14 +262,14 @@ public class EmoneyClient implements Cloneable {
Integer code = loginResult.get("result").get("code").asInt(); Integer code = loginResult.get("result").get("code").asInt();
if (code == 0) { if (code == 0) {
emoneyRequestConfig requestInfo
.setAuthorization( .setAuthorization(
loginResult loginResult
.get("detail").get("token").asText()) .get("detail").get("token").asText())
.setUid( .setUid(
loginResult loginResult
.get("detail").get("uid").asInt()) .get("detail").get("uid").asInt())
.saveOrUpdate(); .insertOrUpdate();
log.info("执行 emoney LOGIN 成功"); log.info("执行 emoney LOGIN 成功");
return true; return true;
} }
@@ -260,12 +302,17 @@ public class EmoneyClient implements Cloneable {
throw new EmoneyIllegalRequestParamException("执行 emoney 请求错误Protocol id 不能为 null!", throw new EmoneyIllegalRequestParamException("执行 emoney 请求错误Protocol id 不能为 null!",
new IllegalArgumentException()); new IllegalArgumentException());
} }
EmoneyRequestConfig emoneyRequestConfig = getEmoneyRequestConfig(); RequestInfo requestInfo = getDefaultRequestInfo();
if (StringUtils.isBlank(emoneyRequestConfig.getAuthorization())) { if (StringUtils.isBlank(requestInfo.getAuthorization())) {
throw new EmoneyIllegalRequestParamException("执行 emoney 请求错误Authorization 为空,是否未登录?", throw new EmoneyIllegalRequestParamException("执行 emoney 请求错误Authorization 为空,是否未登录?",
new IllegalArgumentException()); new IllegalArgumentException());
} }
String url = getUrlByProtocolId(xProtocolId);
if (StringUtils.isBlank(url)) {
throw new EmoneyRequestException("无法根据 xProtocolId " + xProtocolId + "获取请求 URL");
}
try { try {
OkHttpClient okHttpClient = OkHttpClientProvider.getInstance(); OkHttpClient okHttpClient = OkHttpClientProvider.getInstance();
@@ -275,7 +322,7 @@ public class EmoneyClient implements Cloneable {
MediaType.parse("application/x-protobuf-v3")); MediaType.parse("application/x-protobuf-v3"));
Request.Builder requestBuilder = new Request.Builder() Request.Builder requestBuilder = new Request.Builder()
.url(MBS_URL) .url(url)
.post(body) .post(body)
// 这玩意可能也有顺序 // 这玩意可能也有顺序
// 按照 Fiddler HexView 顺序如下: // 按照 Fiddler HexView 顺序如下:
@@ -283,9 +330,9 @@ public class EmoneyClient implements Cloneable {
.header("X-Protocol-Id", xProtocolId.toString()) .header("X-Protocol-Id", xProtocolId.toString())
.header("X-Request-Id", xRequestId == null ? "null" : xRequestId.toString()) .header("X-Request-Id", xRequestId == null ? "null" : xRequestId.toString())
.header("EM-Sign", EncryptUtils.getEMSign(content, "POST", xProtocolId.toString())) .header("EM-Sign", EncryptUtils.getEMSign(content, "POST", xProtocolId.toString()))
.header("Authorization", emoneyRequestConfig.getAuthorization()) .header("Authorization", requestInfo.getAuthorization())
.header("X-Android-Agent", emoneyRequestConfig.getXAndroidAgent()) .header("X-Android-Agent", requestInfo.getXAndroidAgent())
.header("Emapp-ViewMode", emoneyRequestConfig.getEmappViewMode()) .header("Emapp-ViewMode", requestInfo.getEmappViewMode())
; ;
Request request = requestBuilder.build(); Request request = requestBuilder.build();
@@ -293,6 +340,10 @@ public class EmoneyClient implements Cloneable {
final Call call = okHttpClient.newCall(request); final Call call = okHttpClient.newCall(request);
Response response = call.execute(); Response response = call.execute();
// 错误的时候这里是 404比如 strategy 是独立网页的时候Content-Type 是 text/plain
if (response.code() != 200) {
throw new EmoneyRequestException("请求返回错误,状态码 " + response.code());
}
BaseResponse.Base_Response baseResponse = BaseResponse.Base_Response.parseFrom(response.body().bytes()); BaseResponse.Base_Response baseResponse = BaseResponse.Base_Response.parseFrom(response.body().bytes());
return baseResponse; return baseResponse;
} catch (InvalidProtocolBufferNanoException e) { } catch (InvalidProtocolBufferNanoException e) {

View File

@@ -23,7 +23,8 @@ import okhttp3.ResponseBody;
import okio.BufferedSource; import okio.BufferedSource;
import okio.GzipSource; import okio.GzipSource;
import okio.Okio; import okio.Okio;
import quant.rich.emoney.entity.config.ProxyConfig; import quant.rich.emoney.entity.sqlite.ProxySetting;
import quant.rich.emoney.service.sqlite.ProxySettingService;
import quant.rich.emoney.util.SpringContextHolder; import quant.rich.emoney.util.SpringContextHolder;
/** /**
@@ -33,17 +34,18 @@ import quant.rich.emoney.util.SpringContextHolder;
*/ */
public class OkHttpClientProvider { public class OkHttpClientProvider {
private static volatile ProxyConfig proxyConfig; private static volatile ProxySettingService proxySettingService;
private static ProxyConfig getProxyConfig() { private static ProxySetting getDefaultProxySetting() {
if (proxyConfig == null) { if (proxySettingService == null) {
synchronized (OkHttpClientProvider.class) { synchronized (OkHttpClientProvider.class) {
if (proxyConfig == null) { proxySettingService = SpringContextHolder.getBean(ProxySettingService.class);
proxyConfig = SpringContextHolder.getBean(ProxyConfig.class);
} }
} }
if (proxySettingService == null) {
return null;
} }
return proxyConfig; return proxySettingService.getDefaultProxySetting();
} }
/** /**
@@ -60,10 +62,10 @@ public class OkHttpClientProvider {
* @return * @return
*/ */
public static OkHttpClient getInstance(Consumer<OkHttpClient.Builder> builderConsumer) { public static OkHttpClient getInstance(Consumer<OkHttpClient.Builder> builderConsumer) {
ProxyConfig proxyConfig = getProxyConfig(); ProxySetting proxySetting = getDefaultProxySetting();
return getInstance( return getInstance(
proxyConfig.getProxy(), proxySetting.getProxy(),
proxyConfig.getIgnoreHttpsVerification(), proxySetting.getIgnoreHttpsVerification(),
builderConsumer); builderConsumer);
} }

View File

@@ -1,177 +0,0 @@
package quant.rich.emoney.client;
import java.net.Proxy;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.Request;
import com.microsoft.playwright.BrowserType.LaunchOptions;
import com.microsoft.playwright.Route.ResumeOptions;
import com.microsoft.playwright.options.HttpHeader;
import com.microsoft.playwright.options.WaitUntilState;
import lombok.Data;
import lombok.experimental.Accessors;
import quant.rich.emoney.component.LockByCaller;
import quant.rich.emoney.entity.config.EmoneyRequestConfig;
import quant.rich.emoney.entity.config.ProxyConfig;
import quant.rich.emoney.util.SpringContextHolder;
public class WebviewClient {
private static Playwright playwright;
private static boolean isReady = false;
private static volatile ProxyConfig proxyConfig;
private static volatile EmoneyRequestConfig emoneyRequestConfig;
static {
try {
playwright = Playwright.create();
isReady = true;
}
catch (Exception e) {
e.printStackTrace();
}
}
public static boolean isReady() {
return isReady;
}
private static ProxyConfig getProxyConfig() {
if (proxyConfig == null) {
synchronized (WebviewClient.class) {
if (proxyConfig == null) {
proxyConfig = SpringContextHolder.getBean(ProxyConfig.class);
}
}
}
return proxyConfig;
}
private static EmoneyRequestConfig getEmoneyRequestConfig() {
if (emoneyRequestConfig == null) {
synchronized (WebviewClient.class) {
if (emoneyRequestConfig == null) {
emoneyRequestConfig = SpringContextHolder.getBean(EmoneyRequestConfig.class);
}
}
}
return emoneyRequestConfig;
}
@LockByCaller
public static WebviewResponseWrapper getIndexDetailWrapper(String indexCode) {
String proxyUrl = getProxyConfig().getProxyUrl();
LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(true);
// 设置代理
if (StringUtils.isNotBlank(proxyUrl)) {
launchOptions.setProxy(proxyUrl);
}
Browser browser = playwright.chromium().launch(launchOptions);
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
// 设置 Webview User-Agent
.setUserAgent(getEmoneyRequestConfig().getWebviewUserAgent())
// 设置是否忽略 HTTPS 证书
.setIgnoreHTTPSErrors(getProxyConfig().getIgnoreHttpsVerification())
);
// 设置全局请求头
// 根据抓包获得,当前目标版本 5.8.1
Map<String, String> headers = new HashMap<>();
headers.put("Upgrade-Insecure-Requests", "1");
headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9");
headers.put("X-Requested-With", "cn.emoney.emstock");
headers.put("Sec-Fetch-Site", "none");
headers.put("Sec-Fetch-Mode", "navigate");
headers.put("Sec-Fetch-User", "?1");
headers.put("Sec-Fetch-Dest", "document");
headers.put("Accept-Encoding", "gzip, deflate");
headers.put("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7");
context.setExtraHTTPHeaders(headers);
context.route("**/*", route -> {
// 清除 Playwright 添加的额外请求头
Request request = route.request();
List<HttpHeader> requestHeaderList = request.headersArray();
Map<String, String> requestHeaders = new HashMap<>();
for (HttpHeader header : requestHeaderList) {
requestHeaders.put(header.name, header.value);
}
requestHeaders.remove("sec-ch-ua");
requestHeaders.remove("sec-ch-ua-mobile");
requestHeaders.remove("sec-ch-ua-platform");
requestHeaders.remove("Cache-Control");
requestHeaders.remove("Pragma");
requestHeaders.remove("cache-control");
requestHeaders.remove("pragma");
// 判断页面附属请求并进行个性化,以尽可能模仿原生 APP
System.out.format("url: %s, requestType: %s\r\n", request.url(), request.resourceType());
String resourceType = request.resourceType();
if (!"document".equals(resourceType)) {
// 非 document(html) 请求的
requestHeaders.put("Sec-Fetch-Mode", "no-cors");
requestHeaders.remove("Upgrade-Insecure-Requests");
}
if ("image".equals(request.resourceType())) {
// 图片请求
requestHeaders.put("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8");
}
else if ("script".equals(request.resourceType())) {
// 图片请求
requestHeaders.put("Accept", "*/*");
}
route.resume(new ResumeOptions().setHeaders(requestHeaders));
});
Page page = context.newPage();
WebviewResponseWrapper wrapper = new WebviewResponseWrapper();
page.onResponse(handler -> {
String requestUrl = handler.request().url();
if (requestUrl.endsWith(".js")) {
String jsContent = handler.request().response().text();
wrapper.jsMap.put(requestUrl, jsContent);
}
});
StringBuilder urlBuilder = new StringBuilder();
urlBuilder.append("https://appstatic.emoney.cn/html/emapp/stock/note/?name=");
urlBuilder.append(indexCode);
urlBuilder.append("&emoneyScaleType=0&emoneyLandMode=0&token=");
urlBuilder.append(getEmoneyRequestConfig().getAuthorization());
String url = urlBuilder.toString();
page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
wrapper.setRenderedHtml(page.content());
wrapper.setPage(page);
browser.close();
return wrapper;
}
@Data
@Accessors(chain=true)
public static class WebviewResponseWrapper {
private Map<String, String> jsMap = new HashMap<>();
private String renderedHtml;
private Page page;
}
}

View File

@@ -10,6 +10,11 @@ import org.springframework.stereotype.Component;
import quant.rich.emoney.util.CallerLockUtil; import quant.rich.emoney.util.CallerLockUtil;
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 java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
@@ -19,7 +24,7 @@ public class CallerLockAspect {
private final SpelExpressionParser parser = new SpelExpressionParser(); private final SpelExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(com.example.lock.LockByCaller)") @Around("@annotation(quant.rich.emoney.component.CallerLockAspect.LockByCaller)")
public Object around(ProceedingJoinPoint pjp) throws Throwable { public Object around(ProceedingJoinPoint pjp) throws Throwable {
MethodSignature signature = (MethodSignature) pjp.getSignature(); MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod(); Method method = signature.getMethod();
@@ -51,4 +56,25 @@ public class CallerLockAspect {
lock.unlock(); lock.unlock();
} }
} }
/**
* 在方法上添加此注解,可针对调用方加锁,即:<br>
* 调用方法为 A 的,多次从 A 调用则加锁,从 B 调用时不受影响<br>
* 需要开启 AOP在任意配置类上增加注解<i><code>@EnableAspectJAutoProxy</code></i>
* @see CallerLockAspect
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public static @interface LockByCaller {
/**
* 可选参数,用于 SpEL 表达式获取 key
* 例如:<ul>
* <li>@LockByCaller(key = "#userId")</li>
* <li>@LockByCaller(key = "#userId + ':' + #userName")</li>
* </ul>
* 当不指定时,不校验参数,单纯校验 Caller
*/
String key() default "";
}
} }

View File

@@ -19,6 +19,7 @@ import jakarta.validation.ConstraintViolationException;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.UncategorizedSQLException;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -94,15 +95,7 @@ public class EmoneyAutoPlatformExceptionHandler {
if (ex instanceof PageNotFoundException) { if (ex instanceof PageNotFoundException) {
throw (PageNotFoundException) ex; throw (PageNotFoundException) ex;
} }
String message = null; log.warn("Resolved exception {}", ex);
if (ex.getMessage() != null) {
message = ex.getMessage();
}
else if (ex.getCause() != null) {
message = ex.getCause().getMessage();
}
ex.printStackTrace();
log.warn("Resolved exception {}", message);
log.warn(httpServletRequestToString(request)); log.warn(httpServletRequestToString(request));
return bodyOrPage(HttpStatus.INTERNAL_SERVER_ERROR, ex); return bodyOrPage(HttpStatus.INTERNAL_SERVER_ERROR, ex);
} }
@@ -161,27 +154,21 @@ public class EmoneyAutoPlatformExceptionHandler {
} }
private R<?> bodyOrPage(HttpStatus httpStatus, Exception ex) { private R<?> bodyOrPage(HttpStatus httpStatus, Exception ex) {
boolean isPage = true; String message = getMessage(ex, ex instanceof UncategorizedSQLException);
String message = null; boolean isPage = (ex instanceof RException || ex instanceof LoginException) ?
if (ex instanceof RException || false : isPage();
ex instanceof LoginException) {
isPage = false;
message = ex.getMessage();
}
else {
isPage = isPage();
}
if (isPage) { if (isPage) {
if (ex instanceof NoResourceFoundException nrfe) { if (ex instanceof NoResourceFoundException nrfe) {
if (StringUtils.isNotEmpty(nrfe.getMessage()) if (StringUtils.isNotEmpty(nrfe.getMessage())
&& nrfe.getMessage().endsWith(" .well-known/appspecific/com.chrome.devtools.json.")) { && nrfe.getMessage().endsWith(" .well-known/appspecific/com.chrome.devtools.json.")) {
// 傻逼 Chrome 开发工具默认调用该地址 // 傻逼 Chrome 开发工具在本地调试时默认调用该地址
// see: https://blog.ni18.in/well-known-appspecific-com-chrome-devtools-json-request/
return null; return null;
} }
} }
throw ex == null ? new RuntimeException("Page exception raised") : new RuntimeException(ex); throw ex == null ? new RuntimeException("Page exception raised") : new RuntimeException(ex);
} }
R<?> r = message != null ? R<?> r = StringUtils.isNotEmpty(message) ?
R.status(httpStatus).setMessage(message).setData(message) R.status(httpStatus).setMessage(message).setData(message)
: R.status(httpStatus); : R.status(httpStatus);
return r; return r;
@@ -216,4 +203,19 @@ public class EmoneyAutoPlatformExceptionHandler {
return sb.toString(); return sb.toString();
} }
public String getMessage(Throwable e, boolean causeMessageFirst) {
String causeMessage = null;
if (e.getCause() != null && e.getCause() != e) {
if (causeMessageFirst) {
return getMessage(e.getCause(), true);
}
else {
causeMessage = e.getCause().getMessage();
}
}
String mainMessage = e.getMessage();
if (mainMessage != null) return mainMessage;
return causeMessage;
}
} }

View File

@@ -1,24 +0,0 @@
package quant.rich.emoney.component;
import java.lang.annotation.*;
/**
* 在方法上添加此注解,可针对调用方加锁,即:<br>
* 调用方法为 A 的,多次从 A 调用则加锁,从 B 调用时不受影响<br>
* 需要开启 AOP在任意配置类上增加注解<i><code>@EnableAspectJAutoProxy</code></i>
* @see CallerLockAspect
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LockByCaller {
/**
* 可选参数,用于 SpEL 表达式获取 key
* 例如:<ul>
* <li>@LockByCaller(key = "#userId")</li>
* <li>@LockByCaller(key = "#userId + ':' + #userName")</li>
* </ul>
* 当不指定时,不校验参数,单纯校验 Caller
*/
String key() default "";
}

View File

@@ -0,0 +1,83 @@
package quant.rich.emoney.component;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import quant.rich.emoney.client.EmoneyClient;
import quant.rich.emoney.entity.sqlite.ProxySetting;
import quant.rich.emoney.entity.sqlite.RequestInfo;
import quant.rich.emoney.service.sqlite.ProxySettingService;
import quant.rich.emoney.service.sqlite.RequestInfoService;
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 java.lang.reflect.Method;
@Component
@Aspect
public class RequireAuthAndProxyAspect {
@Autowired
RequestInfoService requestInfoService;
@Autowired
ProxySettingService proxySettingService;
@Around("@annotation(quant.rich.emoney.component.RequireAuthAndProxyAspect.RequireAuthAndProxy)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
ProxySetting defualtProxySetting = proxySettingService.getDefaultProxySetting();
if (defualtProxySetting == null) {
throw new RuntimeException("需要配置默认代理设置");
}
RequestInfo defaultRequestInfo = requestInfoService.getDefaultRequestInfo();
if (defaultRequestInfo == null) {
throw new RuntimeException("需要配置默认请求信息");
}
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
RequireAuthAndProxy annotation = method.getAnnotation(RequireAuthAndProxy.class);
if (StringUtils.isBlank(defaultRequestInfo.getAuthorization())) {
if (!annotation.autoLogin()) {
throw new RuntimeException("需要手动为请求信息鉴权");
}
if (!EmoneyClient.loginWithManaged()) {
throw new RuntimeException("鉴权登录失败");
}
}
else if (!EmoneyClient.relogin()) {
throw new RuntimeException("检查重鉴权失败");
}
return pjp.proceed();
}
/**
* 在方法上添加此注解,则进入该方法前先校验 defaultRequestInfo 已鉴权、代理已配置,否则不允许进入方法
* <p>需要开启 AOP在任意配置类上增加注解<i><code>@EnableAspectJAutoProxy</code></i>
* @see RequireAuthAndProxyAspect
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public static @interface RequireAuthAndProxy {
/**
* 当存在默认请求配置但未鉴权时,是否自动鉴权
* @return
*/
boolean autoLogin() default false;
}
}

View File

@@ -13,7 +13,9 @@ import quant.rich.emoney.interfaces.ConfigInfo;
import quant.rich.emoney.interfaces.IConfig; import quant.rich.emoney.interfaces.IConfig;
/** /**
* 实现自动化注册 Config * 实现自动化注册 Config<p>
* Config 放在 quant.rich.emoney.entity.config 包下并且必须实现 IConfig 接口
* @see quant.rich.emoney.interfaces.IConfig
*/ */
@Slf4j @Slf4j
@DependsOn("configService") @DependsOn("configService")
@@ -28,6 +30,7 @@ public class ConfigAutoRegistrar implements BeanDefinitionRegistryPostProcessor
scanner.findCandidateComponents("quant.rich.emoney.entity.config").forEach(beanDefinition -> { scanner.findCandidateComponents("quant.rich.emoney.entity.config").forEach(beanDefinition -> {
String className = beanDefinition.getBeanClassName(); String className = beanDefinition.getBeanClassName();
try { try {
// 确保其 field 规则与 configService 内 field 生成规则一致,即: // 确保其 field 规则与 configService 内 field 生成规则一致,即:
// 如果 @ConfigInfo 指定了 field 的,使用该 field + "Config" // 如果 @ConfigInfo 指定了 field 的,使用该 field + "Config"
// 作为 beanName否则使用首字母小写的 simpleClassName 作为 // 作为 beanName否则使用首字母小写的 simpleClassName 作为
@@ -37,18 +40,18 @@ public class ConfigAutoRegistrar implements BeanDefinitionRegistryPostProcessor
+ clazz.getSimpleName().substring(1); + clazz.getSimpleName().substring(1);
if (!IConfig.class.isAssignableFrom(clazz)) { if (!IConfig.class.isAssignableFrom(clazz)) {
log.warn("Config {} does not implement IConfig, ignore", beanName); log.error("Ignore config class {} which is not implemented IConfig interface", beanName);
return; return;
} }
if (!beanName.endsWith("Config")) { if (!beanName.endsWith("Config")) {
log.warn("Config {}'s simple class name does not end with \"Config\", ignore", beanName); log.error("Ignore config class {} which class name is not end with \"Config\"", beanName);
return; return;
} }
ConfigInfo info = clazz.getAnnotation(ConfigInfo.class); ConfigInfo info = clazz.getAnnotation(ConfigInfo.class);
if (info == null) { if (info == null) {
log.warn("Config {} does not have @ConfigInfo annotation, ignore", clazz.getName()); log.error("Ignore config class {} which is not annotated with @ConfigInfo", clazz.getName());
return; return;
} }
if (StringUtils.isNotBlank(info.field())) { if (StringUtils.isNotBlank(info.field())) {
@@ -58,15 +61,16 @@ public class ConfigAutoRegistrar implements BeanDefinitionRegistryPostProcessor
BeanDefinitionBuilder factoryBean = BeanDefinitionBuilder BeanDefinitionBuilder factoryBean = BeanDefinitionBuilder
.genericBeanDefinition(ConfigServiceFactoryBean.class) .genericBeanDefinition(ConfigServiceFactoryBean.class)
.addConstructorArgValue(clazz); .addConstructorArgValue(clazz);
// 注意此处注册 factoryBean 不意味着 FactoryBean.getObject() 方法立即被执行, /**
// Spring 管理的 Bean 默认在其被使用时才创建,所以如果 getObject() 调用一些方 * 注意此处通过 factoryBean 创建 bean 不意味着 FactoryBean.getObject() 方法
// 法,这些方法会在初次使用 Bean 时才创建。如果这些方法对于启动过程很重要, * 会被立即执行。Spring 默认会在 bean 被使用时才创建。如果该 bean 对程序
// 需要在对应 Config(Bean) 上加上 @Bean @Lazy(false) 注解,确保一旦准备好 * 启动很重要,需要立即创建的,需在其类上添加 @Bean @Lazy(false) 注解,
// 相应的 Bean 就会被创建 * 确保一旦准备好,相应的 bean 就会被创建
*/
registry.registerBeanDefinition(beanName, factoryBean.getBeanDefinition()); registry.registerBeanDefinition(beanName, factoryBean.getBeanDefinition());
log.info("Add config {} to bean register", beanName); log.info("Add config class {} to bean register", beanName);
} catch (ClassNotFoundException e) { } catch (ClassNotFoundException e) {
throw new RuntimeException("Failed to load class: " + className, e); throw new RuntimeException("Cannot found specific config class: " + className, e);
} }
}); });
} }

View File

@@ -9,8 +9,9 @@ import quant.rich.emoney.interfaces.IConfig;
import quant.rich.emoney.service.ConfigService; import quant.rich.emoney.service.ConfigService;
/** /**
* 实现配置项自动载入 * 配置类工厂
* @param <T> * @param <T> 配置类
*
*/ */
@Slf4j @Slf4j
public class ConfigServiceFactoryBean<T extends IConfig<T>> implements FactoryBean<T>, BeanNameAware { public class ConfigServiceFactoryBean<T extends IConfig<T>> implements FactoryBean<T>, BeanNameAware {
@@ -37,24 +38,19 @@ public class ConfigServiceFactoryBean<T extends IConfig<T>> implements FactoryBe
@Override @Override
public T getObject() throws Exception { public T getObject() throws Exception {
ConstructionGuard.enter(targetClass); ConstructionGuard.enter(targetClass);
boolean success = true;
try { try {
T bean = configService.getConfig(targetClass); T bean = configService.getConfig(targetClass);
beanFactory.autowireBean(bean); beanFactory.autowireBean(bean);
beanFactory.initializeBean(bean, beanName); beanFactory.initializeBean(bean, beanName);
configService.saveOrUpdate(bean); //configService.saveOrUpdate(bean);
return bean; return bean;
} }
catch (Exception e) { catch (Exception e) {
log.error("Fail to load config: " + targetClass.getName(), e); log.error("无法载入配置类: " + targetClass.getName(), e);
success = false;
throw e; throw e;
} }
finally { finally {
ConstructionGuard.exit(targetClass); ConstructionGuard.exit(targetClass);
if (success) {
log.debug("getObject() for {} success", targetClass.toString());
}
} }
} }

View File

@@ -7,6 +7,25 @@ import org.springframework.beans.factory.BeanCreationException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/**
* To prevent bean cyclic instantiation through BeanFactory:<p>
* The reason is that some configuration classes are deserialized via json.
* During this process, the default behavior is to call their no-argument
* constructors. Inside these constructors, its likely that static methods
* from SpringContextHolder are invoked to obtain other configuration classes.
* However, those other configuration classes may in turn call the same static
* methods of SpringContextHolder to obtain yet other configuration classes.<p>
* At this point, since the configuration class is still {@code null}, Spring attempts
* once again to produce the instance of this class via BeanFactory, leading to
* a cyclic instantiation process and eventually causing a stack overflow.<p>
* Since SpringContextHolder actually operates outside of Springs management lifecycle,
* it is difficult to detect this issue at runtime. Therefore, this class should
* be used within the Factorys getObject method, where an exception is thrown
* if cyclic instantiation occurs. The implementation of this class is based on ThreadLocal.
*
* @author Doghole
* @see ConfigServiceFactoryBean
*/
@Slf4j @Slf4j
public class ConstructionGuard { public class ConstructionGuard {
private static final ThreadLocal<Set<Class<?>>> constructing = ThreadLocal.withInitial(HashSet::new); private static final ThreadLocal<Set<Class<?>>> constructing = ThreadLocal.withInitial(HashSet::new);
@@ -16,7 +35,6 @@ public class ConstructionGuard {
} }
public static void enter(Class<?> clazz) { public static void enter(Class<?> clazz) {
log.debug("Enter construction for {}", clazz.toString());
if (isConstructing(clazz)) { if (isConstructing(clazz)) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("Class ") sb.append("Class ")
@@ -31,6 +49,5 @@ public class ConstructionGuard {
public static void exit(Class<?> clazz) { public static void exit(Class<?> clazz) {
constructing.get().remove(clazz); constructing.get().remove(clazz);
log.debug("Exit construction for {}", clazz.toString());
} }
} }

View File

@@ -20,6 +20,8 @@ import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
@MapperScan(basePackages = "quant.rich.emoney.mapper.postgre", sqlSessionTemplateRef = "postgreSqlSessionTemplate") @MapperScan(basePackages = "quant.rich.emoney.mapper.postgre", sqlSessionTemplateRef = "postgreSqlSessionTemplate")
public class PostgreMybatisConfig { public class PostgreMybatisConfig {
public static final String POSTGRE_TRANSACTION_MANAGER = "postgreTransactionManager";
@Bean("postgreSqlSessionFactory") @Bean("postgreSqlSessionFactory")
public SqlSessionFactory postgreSqlSessionFactory( public SqlSessionFactory postgreSqlSessionFactory(
@Qualifier("postgreDataSource") DataSource dataSource) throws Exception { @Qualifier("postgreDataSource") DataSource dataSource) throws Exception {
@@ -40,7 +42,7 @@ public class PostgreMybatisConfig {
return new SqlSessionTemplate(sqlSessionFactory); return new SqlSessionTemplate(sqlSessionFactory);
} }
@Bean("postgreTransactionManager") @Bean(POSTGRE_TRANSACTION_MANAGER)
public DataSourceTransactionManager postgreTransacetionManager(@Qualifier("postgreDataSource") DataSource dataSource) { public DataSourceTransactionManager postgreTransacetionManager(@Qualifier("postgreDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource); return new DataSourceTransactionManager(dataSource);
} }

View File

@@ -29,6 +29,7 @@ public class SecurityConfig {
.headers(headers -> headers.cacheControl(cache -> cache.disable())) .headers(headers -> headers.cacheControl(cache -> cache.disable()))
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/favicon.ico").permitAll()
.requestMatchers("/admin/*/login").permitAll() .requestMatchers("/admin/*/login").permitAll()
.requestMatchers("/admin/*/static/**").permitAll() .requestMatchers("/admin/*/static/**").permitAll()
.requestMatchers("/public/**").permitAll() .requestMatchers("/public/**").permitAll()

View File

@@ -1,5 +1,9 @@
package quant.rich.emoney.config; package quant.rich.emoney.config;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.sql.DataSource; import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionFactory;
@@ -8,6 +12,7 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.annotation.DbType;
@@ -15,16 +20,90 @@ import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.EmoneyAutoApplication;
@Slf4j
@Configuration @Configuration
@MapperScan(basePackages = "quant.rich.emoney.mapper.sqlite", sqlSessionTemplateRef = "sqliteSqlSessionTemplate") @MapperScan(basePackages = "quant.rich.emoney.mapper.sqlite", sqlSessionTemplateRef = "sqliteSqlSessionTemplate")
public class SqliteMybatisConfig { public class SqliteMybatisConfig {
private static final String RESOURCE_PATH = "database.db";
public static final String SQLITE_TRANSACTION_MANAGER = "sqliteTransactionManager"; public static final String SQLITE_TRANSACTION_MANAGER = "sqliteTransactionManager";
/**
* 配置数据库连接
* <ul>
* <li>如果非打包状态,则直接选取当前项目内数据库位置</li>
* <li>如果打包状态,以 JDBC 链接位置为主,如果位置不存在则覆盖</li>
* </ul>
* @param dataSource
*/
public void initSQLiteLocation(DataSource dataSource) {
// 指定 sqlite 路径
if (dataSource instanceof HikariDataSource hikariDataSource) {
String filePath = hikariDataSource.getJdbcUrl();
if (filePath == null || !filePath.startsWith("jdbc:sqlite:")) {
log.warn(
"无法在 SQLite HikariDataSource 中找到合法 SQLite JDBC url, "
+ "数据库可能会加载失败。合法的 url 需在 application.yml(properties) "
+ "中配置,以 jdbc:sqlite: 开头。当前获取到的 jdbc-url: {}", filePath);
return;
}
filePath = filePath.substring("jdbc:sqlite:".length()).trim();
ClassPathResource original = new ClassPathResource(RESOURCE_PATH);
if (!original.exists()) {
log.warn("未找到 SQLite 资源: {}", RESOURCE_PATH);
return;
}
String protocol = EmoneyAutoApplication.class.getProtectionDomain().getCodeSource().getLocation().getProtocol();
boolean isJar = "jar".equals(protocol);
if (isJar) {
// 复制到外部 yml 指定路径,已存在则不复制
File dest = new File(filePath), parentDir = dest.getParentFile();
String destAbsolutePath = dest.getAbsolutePath();
if (!parentDir.exists() && !parentDir.mkdirs()) {
log.warn("无法创建放置 SQLite 文件的目录: {}", parentDir.getAbsolutePath());
return;
}
if (dest.exists()) {
// 已存在
log.warn("目标资源 {} 已存在,忽略", destAbsolutePath);
return;
}
try (InputStream in = getClass().getClassLoader().getResourceAsStream(RESOURCE_PATH)) {
if (in == null) {
log.warn("无法读取 SQLite 资源: {}", RESOURCE_PATH);
return;
}
Files.copy(in, dest.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
log.info("SQLite 数据库文件已复制至:{}", destAbsolutePath);
} catch (Exception e) {
log.warn("复制 SQLite 数据库文件失败", e);
}
}
else {
// 使用当前绝对路径
Path path = Path.of("src/main/resources", RESOURCE_PATH);
hikariDataSource.setJdbcUrl("jdbc:sqlite:" + path.toAbsolutePath().toString());
}
}
}
@Bean("sqliteSqlSessionFactory") @Bean("sqliteSqlSessionFactory")
public SqlSessionFactory sqliteSqlSessionFactory( public SqlSessionFactory sqliteSqlSessionFactory(
@Qualifier("sqliteDataSource") DataSource dataSource) throws Exception { @Qualifier("sqliteDataSource") DataSource dataSource) throws Exception {
initSQLiteLocation(dataSource);
MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean(); MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource); factory.setDataSource(dataSource);
@@ -42,7 +121,7 @@ public class SqliteMybatisConfig {
return new SqlSessionTemplate(sqlSessionFactory); return new SqlSessionTemplate(sqlSessionFactory);
} }
@Bean("sqliteTransactionManager") @Bean(SQLITE_TRANSACTION_MANAGER)
public DataSourceTransactionManager postgreTransacetionManager(@Qualifier("sqliteDataSource") DataSource dataSource) { public DataSourceTransactionManager postgreTransacetionManager(@Qualifier("sqliteDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource); return new DataSourceTransactionManager(dataSource);
} }

View File

@@ -52,9 +52,10 @@ public class IndexControllerV1 extends BaseController {
String username, String username,
String password, String password,
String newPassword, String newPassword,
String email) { String email,
String apiToken) {
if (passwordIsNotEmpty(newPassword)) { if (EncryptUtils.passwordIsNotEmpty(newPassword)) {
if (!platformConfig.getPassword().equals(password)) { if (!platformConfig.getPassword().equals(password)) {
throw RException.badRequest("密码错误"); throw RException.badRequest("密码错误");
} }
@@ -66,7 +67,7 @@ public class IndexControllerV1 extends BaseController {
else { else {
throw RException.badRequest("用户名不能为空"); throw RException.badRequest("用户名不能为空");
} }
platformConfig.setEmail(email); platformConfig.setEmail(email).setApiToken(apiToken);
return R.judge(() -> { return R.judge(() -> {
if (configService.saveOrUpdate(platformConfig)) { if (configService.saveOrUpdate(platformConfig)) {
authService.setLogin(username, platformConfig.getPassword()); authService.setLogin(username, platformConfig.getPassword());
@@ -75,10 +76,4 @@ public class IndexControllerV1 extends BaseController {
return false; return false;
}); });
} }
static final String EMPTY_PASSWORD = EncryptUtils.sha3("", 224);
static boolean passwordIsNotEmpty(String password) {
return StringUtils.isNotEmpty(password) && !password.equalsIgnoreCase(EMPTY_PASSWORD);
}
} }

View File

@@ -18,6 +18,7 @@ import quant.rich.emoney.pojo.dto.R;
import quant.rich.emoney.service.AuthService; import quant.rich.emoney.service.AuthService;
import quant.rich.emoney.service.ConfigService; import quant.rich.emoney.service.ConfigService;
import quant.rich.emoney.util.EncryptUtils; import quant.rich.emoney.util.EncryptUtils;
import quant.rich.emoney.util.TextUtils;
@Controller @Controller
@RequestMapping("/admin/v1") @RequestMapping("/admin/v1")
@@ -61,7 +62,7 @@ public class LoginControllerV1 extends BaseController {
if (Objects.isNull(sessionCaptcha) || !captcha.equalsIgnoreCase(sessionCaptcha.toString())) { if (Objects.isNull(sessionCaptcha) || !captcha.equalsIgnoreCase(sessionCaptcha.toString())) {
throw new LoginException("验证码错误"); throw new LoginException("验证码错误");
} }
if (StringUtils.isAnyBlank(username) || !passwordIsNotEmpty(password)) { if (StringUtils.isAnyBlank(username) || !EncryptUtils.passwordIsNotEmpty(password)) {
throw new LoginException("用户名和密码不能为空"); throw new LoginException("用户名和密码不能为空");
} }
if (!username.equals(platformConfig.getUsername()) if (!username.equals(platformConfig.getUsername())
@@ -81,10 +82,14 @@ public class LoginControllerV1 extends BaseController {
} }
// 初始化流程 // 初始化流程
if (StringUtils.isAnyBlank(username) || !passwordIsNotEmpty(password)) { if (StringUtils.isAnyBlank(username) || !EncryptUtils.passwordIsNotEmpty(password)) {
throw new LoginException("用户名和密码不能为空"); throw new LoginException("用户名和密码不能为空");
} }
platformConfig.setUsername(username).setPassword(password).setIsInited(true); platformConfig
.setUsername(username)
.setPassword(password)
.setIsInited(true)
.setApiToken(TextUtils.randomString(16));
boolean success = configService.saveOrUpdate(platformConfig); boolean success = configService.saveOrUpdate(platformConfig);
if (!success) { if (!success) {
throw new LoginException("无法配置用户名和密码,请检查"); throw new LoginException("无法配置用户名和密码,请检查");
@@ -99,10 +104,4 @@ public class LoginControllerV1 extends BaseController {
return "redirect:/admin/v1/login"; return "redirect:/admin/v1/login";
} }
static final String EMPTY_PASSWORD = EncryptUtils.sha3("", 224);
static boolean passwordIsNotEmpty(String password) {
return StringUtils.isNotEmpty(password) && !password.equalsIgnoreCase(EMPTY_PASSWORD);
}
} }

View File

@@ -0,0 +1,58 @@
package quant.rich.emoney.controller.api;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.NonNull;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.protobuf.nano.MessageNano;
import org.apache.commons.lang3.StringUtils;
import org.reflections.Reflections;
import lombok.extern.slf4j.Slf4j;
import nano.BaseResponse.Base_Response;
import quant.rich.emoney.entity.sqlite.ProtocolMatch;
import quant.rich.emoney.exception.RException;
import quant.rich.emoney.interfaces.IQueryableEnum;
import quant.rich.emoney.pojo.dto.EmoneyConvertResult;
import quant.rich.emoney.pojo.dto.EmoneyProtobufBody;
import quant.rich.emoney.service.sqlite.ProtocolMatchService;
@RestController
@RequestMapping("/api/v1/common")
@Slf4j
public class CommonAbilityControllerV1 {
@Autowired
Reflections reflections;
@GetMapping("/getIQueryableEnum")
public Map<String, String> getIQueryableEnum(String enumName) {
Set<Class<? extends IQueryableEnum>> enums = reflections.getSubTypesOf(IQueryableEnum.class);
Map<String, String> map = new HashMap<>();
for (Class<? extends IQueryableEnum> clazz : enums) {
if (clazz.getSimpleName().equals(enumName) && clazz.isEnum()) {
@SuppressWarnings({ "unchecked", "rawtypes" })
Class<? extends Enum> enumClass = (Class<? extends Enum<?>>) clazz;
for (Enum<?> e : enumClass.getEnumConstants()) {
if (e instanceof IQueryableEnum iqe) {
map.put(e.name(), iqe.getNote());
}
}
break;
}
}
return map;
}
}

View File

@@ -1,6 +1,15 @@
package quant.rich.emoney.controller.api; package quant.rich.emoney.controller.api;
import java.io.Serializable;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
@@ -9,27 +18,107 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.protobuf.nano.MessageNano; import com.google.protobuf.nano.MessageNano;
import org.apache.commons.lang3.StringUtils; import jakarta.annotation.PostConstruct;
import org.apache.commons.lang3.StringUtils;
import org.reflections.Reflections;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import nano.BaseResponse.Base_Response; import nano.BaseResponse.Base_Response;
import quant.rich.emoney.annotation.ResponseDecodeExtension;
import quant.rich.emoney.entity.sqlite.ProtocolMatch; import quant.rich.emoney.entity.sqlite.ProtocolMatch;
import quant.rich.emoney.exception.RException; import quant.rich.emoney.exception.RException;
import quant.rich.emoney.pojo.dto.EmoneyConvertResult; import quant.rich.emoney.pojo.dto.EmoneyConvertResult;
import quant.rich.emoney.pojo.dto.EmoneyProtobufBody; import quant.rich.emoney.pojo.dto.EmoneyProtobufBody;
import quant.rich.emoney.service.sqlite.ProtocolMatchService; import quant.rich.emoney.service.sqlite.ProtocolMatchService;
import quant.rich.emoney.util.SpringBeanDetector;
import quant.rich.emoney.util.SpringContextHolder;
/**
* 益盟 ProtocolBuf 报文解析 API 控制器
*/
@RestController @RestController
@RequestMapping("/api/v1/proto") @RequestMapping("/api/v1/proto")
@Slf4j @Slf4j
public class ProtoDecodeControllerV1 { public class ProtoDecodeControllerV1 {
@Autowired @Autowired
private ProtocolMatchService protocolMatchService; ProtocolMatchService protocolMatchService;
@Autowired
Reflections reflections;
Map<String, List<MethodInfo>> responseDecodeExtensions = new HashMap<String, List<MethodInfo>>();
@Data
@RequiredArgsConstructor
private static class MethodInfo {
final Method method;
final Class<?> declaringClass;
final Integer order;
Object instance;
}
@PostConstruct
void postConstruct() {
// Reflections 扫描所有注解并根据 protocolId 和 order 排序
Set<Method> methods = reflections.getMethodsAnnotatedWith(ResponseDecodeExtension.class);
for (Method m : methods) {
MethodInfo info;
ResponseDecodeExtension ex = m.getAnnotation(ResponseDecodeExtension.class);
String protocolId = ex.protocolId();
Integer order = ex.order();
// 判断 method 是否为单参数接受 JsonNode 的方法
Class<?>[] parameterTypes = m.getParameterTypes();
Class<?> declaringClass = m.getDeclaringClass();
if (parameterTypes.length != 1 || parameterTypes[0] != JsonNode.class) {
log.warn("方法 {}#{} 不为类型为 JsonNode 的单参数方法,暂不支持作为解码额外选项",
declaringClass.getSimpleName(), m.getName(), declaringClass.getSimpleName());
continue;
}
// 判断 method 是否是静态
if (Modifier.isStatic(m.getModifiers())) {
info = new MethodInfo(m, null, order);
}
else {
if (!SpringBeanDetector.isSpringManagedClass(declaringClass)) {
log.warn("方法 {} 所属类 {} 不归属于 Spring 管理,目前暂不支持作为解码额外选项",
m.getName(), declaringClass.getSimpleName());
continue;
}
info = new MethodInfo(m, declaringClass, order);
}
List<MethodInfo> list = responseDecodeExtensions.get(protocolId);
if (list == null) {
list = new ArrayList<>();
list.add(info);
responseDecodeExtensions.put(protocolId, list);
}
else {
list.add(info);
}
}
for (List<MethodInfo> list : responseDecodeExtensions.values()) {
list.sort(Comparator.comparingInt(info -> info.getOrder()));
}
log.debug("ResponseDecodeExtension: 共载入 {} 个 ProtocolID 的 {} 个方法",
responseDecodeExtensions.keySet().size(), responseDecodeExtensions.values().size());
}
/**
* 解析 emoney protobuf 的请求
* @param <U>
* @param body
* @return
*/
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@PostMapping("/request/decode") @PostMapping("/request/decode")
public <U extends MessageNano> EmoneyConvertResult requestDecode( public <U extends MessageNano> EmoneyConvertResult requestDecode(
@@ -39,13 +128,14 @@ public class ProtoDecodeControllerV1 {
Integer protocolId = body.getProtocolId(); Integer protocolId = body.getProtocolId();
if (Objects.isNull(protocolId)) { if (Objects.isNull(protocolId)) {
throw RException.badRequest("protocolId cannot be null"); throw RException.badRequest("protocolId 不能为 null");
} }
ProtocolMatch match = protocolMatchService.getById(protocolId); ProtocolMatch match = protocolMatchService.getById(protocolId);
if (Objects.isNull(match) || StringUtils.isBlank(match.getClassName())) { if (Objects.isNull(match) || StringUtils.isBlank(match.getClassName())) {
throw RException.badRequest("暂无对应 protocolId = " + protocolId + " 的记录,可等 response decoder 搜集到后重试"); throw RException
.badRequest("暂无对应 protocolId = ", protocolId, " 的记录,可等待 response decoder 收集到后再重试");
} }
String className = new StringBuilder() String className = new StringBuilder()
@@ -111,7 +201,7 @@ public class ProtoDecodeControllerV1 {
Integer protocolId = body.getProtocolId(); Integer protocolId = body.getProtocolId();
ProtocolMatch match = null; ProtocolMatch match = null;
if (Objects.isNull(protocolId)) { if (Objects.isNull(protocolId)) {
log.warn("protocolId is null, cannot update protocolMatch"); log.warn("protocolId 为空 null, 无法更新 protocolMatch");
} }
else { else {
match = protocolMatchService.getById(protocolId); match = protocolMatchService.getById(protocolId);
@@ -178,8 +268,32 @@ public class ProtoDecodeControllerV1 {
U nano = (U)MessageNano.mergeFrom( U nano = (U)MessageNano.mergeFrom(
(MessageNano)clazz.getDeclaredConstructor().newInstance(), (MessageNano)clazz.getDeclaredConstructor().newInstance(),
baseResponse.detail.getValue()); baseResponse.detail.getValue());
JsonNode jo = new ObjectMapper().valueToTree(nano);
// 查找 ResponseDecodeExtension
List<MethodInfo> methodInfos = responseDecodeExtensions.get(protocolId.toString());
if (methodInfos != null) {
for (MethodInfo methodInfo : methodInfos) {
if (methodInfo.getInstance() != null) {
// instance 不为 null 则说明是已经取到的 spring bean, 直接调用
methodInfo.getMethod().invoke(methodInfo.getInstance(), jo);
}
else if (methodInfo.getDeclaringClass() != null) {
// 获取 spring 管理的实例类
Object instance = SpringContextHolder.getBean(methodInfo.getDeclaringClass());
methodInfo.getMethod().invoke(instance, jo);
methodInfo.setInstance(instance);
}
else {
// 静态方法直接 invoke
methodInfo.getMethod().invoke(null, jo);
}
}
}
return EmoneyConvertResult return EmoneyConvertResult
.ok(new ObjectMapper().valueToTree(nano)) .ok((Serializable)jo)
.setProtocolId(protocolId) .setProtocolId(protocolId)
.setSupposedClassName(className); .setSupposedClassName(className);
} }

View File

@@ -26,5 +26,4 @@ public abstract class BaseController {
protected Boolean isLogin() { protected Boolean isLogin() {
return authService.isLogin(); return authService.isLogin();
} }
} }

View File

@@ -0,0 +1,122 @@
package quant.rich.emoney.controller.common;
import java.io.Serializable;
import java.lang.reflect.InvocationTargetException;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.core.ResolvableType;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.exception.RException;
import quant.rich.emoney.pojo.dto.LayPageReq;
import quant.rich.emoney.pojo.dto.LayPageResp;
import quant.rich.emoney.pojo.dto.R;
/**
* 在控制器中提供实体类的 service<p>
*
* 继承后,可直接通过 {@code thisType} 和 {@code thisService} 获取实体类型和对应的服务实例<p>
* 也获得部分能力,但控制方法及 Mapping 路径要继承后自己写。<p>
* 可获得的能力:
* <ul>
* <li>list
* <li>getOne
* <li>delete
* <ul>
*
* @param <T> 实体类型
*/
@Slf4j
public abstract class ServiceController<T> extends BaseController {
@Autowired
private ApplicationContext ctx;
protected IService<?> thisService;
protected Class<?> thisType;
@PostConstruct
void init() {
@SuppressWarnings("rawtypes")
Map<String, IService> beans = ctx.getBeansOfType(IService.class);
ResolvableType thisType = ResolvableType.forClass(this.getClass()).as(ServiceController.class);
@SuppressWarnings("unchecked")
// 获取本类的实体类
Class<T> clazz = (Class<T>) thisType.getGeneric(0).resolve();
this.thisType = clazz;
for (IService<?> service : beans.values()) {
ResolvableType type = ResolvableType.forClass(service.getClass()).as(IService.class);
Class<?> entityType = type.getGeneric(0).resolve();
if (entityType == clazz) {
this.thisService = service;
}
}
if (thisService == null) {
log.error("获取本例实体类服务失败,请检查");
}
}
@SuppressWarnings("unchecked")
protected IService<T> getThisService() {
return (IService<T>)this.thisService;
}
/**
* 返回实例类列表
* @param pageReq
* @return
*/
protected LayPageResp<?> list(LayPageReq<T> pageReq) {
Page<T> planPage = getThisService().page(pageReq);
return new LayPageResp<>(planPage);
}
/**
* 根据 id 获取实例化对象。如果 id 为空则返回通过默认无参构造器构造的新实例化对象
* @param id
* @return
*/
protected R<?> getOne(Serializable id) {
// id 为空,返回一个新实例化对象
if (id == null) {
try {
return R.ok(thisType.getConstructor().newInstance());
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException
| InvocationTargetException | NoSuchMethodException | SecurityException e) {
final String s = "根据默认构造器创建新实例化对象失败";
log.error(s, e);
throw RException.internalServerError(s);
}
}
// 否则从数据库取
T exist = getThisService().getById(id);
return R.judge(exist != null, exist, "无法找到对应 ID 的 ProxySetting");
}
/**
* 保存
* @param object
* @return
*/
protected R<?> save(T object) {
return
R.judge(
() -> getThisService().saveOrUpdate(object),
"新增或保存失败");
}
/**
* 删除
* @param id
* @return
*/
protected R<?> delete(Serializable id) {
return R.judge(getThisService().removeById(id), "删除失败,是否已删除?");
}
}

View File

@@ -0,0 +1,86 @@
package quant.rich.emoney.controller.common;
import java.lang.reflect.Field;
import java.util.Optional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
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 com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.exception.RException;
import quant.rich.emoney.pojo.dto.R;
/**
* 更新实体类中 Boolean 字段的抽象控制器,一般用于实体类中包含 Boolean 字段的前端更新
* <p>
* 前端实体类数据列表中,常有 CheckBox 或 Switch 等控件,希望通过点击数据表中的控件直接修改行对象
* Boolean 值的,可用该方法。需要引入功能的需 extends 本类,如对 {@code Plan} 生效,则可在其控制器
* {@code PlanController} 中:<p>
*
* <code> PlanController <b>extends</b> UpdateBoolController&lt;Plan></code>
* @param <T> 实体类型
* @see #updateBool(String, String, Boolean)
*/
@Slf4j
public abstract class UpdateBoolServiceController<T> extends ServiceController<T> {
protected ObjectMapper mapper = new ObjectMapper();
/**
* 更新布尔值主方法,以 form 形式 POSTuri: /updateBool表单字段名需与该方法参数名一致
* @param id 欲修改的实体类的实例化对象的主键值
* @param field 欲修改的实体类的实例化对象的布尔字段名
* @param value 需要修改为的布尔值
* @return
*/
@PostMapping("/updateBool")
@ResponseBody
protected
R<?> updateBool(String id, String field, Boolean value) {
// 获取表信息
TableInfo tableInfo = TableInfoHelper.getTableInfo(thisType);
Object converted = mapper.convertValue(id, tableInfo.getKeyType());
// 获取 Service
try {
// 获取主键名
String idField = tableInfo.getKeyColumn();
// 获取指定布尔字段的字段信息
Field declaredField = thisType.getDeclaredField(field);
// 获取指定布尔字段在数据表中的映射字段信息
Optional<TableFieldInfo> fieldInfo = tableInfo.getFieldList().stream()
.filter(f -> f.getProperty().equals(field))
.findFirst();
if (fieldInfo.isEmpty()) {
throw RException.badRequest("无法根据 field: " + field + " 找到类内字段信息");
}
if (declaredField.getType().equals(Boolean.class)
|| declaredField.getType().equals(boolean.class)
) {
return R.judge(getThisService().update(
new UpdateWrapper<T>()
.set(fieldInfo.get().getColumn(), value)
.eq(idField, converted)
), "更新失败,请查看日志");
}
else {
throw RException.badRequest("field: " + field + " 不为布尔值类型字段");
}
}
catch (NoSuchFieldException | SecurityException e) {
throw RException.badRequest("获取字段 " + field + " 错误");
}
}
}

View File

@@ -1,28 +0,0 @@
package quant.rich.emoney.controller.config;
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.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.controller.common.BaseController;
import quant.rich.emoney.entity.config.ProxyConfig;
import quant.rich.emoney.pojo.dto.R;
@Slf4j
@Controller
@RequestMapping("/admin/v1/config/proxy")
public class ProxyConfigControllerV1 extends BaseController {
@Autowired
ProxyConfig proxyConfig;
@GetMapping("/refreshIpThroughProxy")
@ResponseBody
public R<?> refreshIpThroughProxy() {
return R.ok(proxyConfig.refreshIpThroughProxy());
}
}

View File

@@ -1,9 +1,9 @@
package quant.rich.emoney.controller.manage; package quant.rich.emoney.controller.manage;
import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -25,7 +25,7 @@ import quant.rich.emoney.service.IndexDetailService;
public class IndexInfoControllerV1 extends BaseController { public class IndexInfoControllerV1 extends BaseController {
@Autowired @Autowired
IndexInfoConfig indexInfo; IndexInfoConfig indexInfoConfig;
@Autowired @Autowired
IndexDetailService indexDetailService; IndexDetailService indexDetailService;
@@ -35,6 +35,11 @@ public class IndexInfoControllerV1 extends BaseController {
return "/admin/v1/manage/indexInfo/index"; return "/admin/v1/manage/indexInfo/index";
} }
/**
* 获取指标详情解释
* @param indexCode
* @return
*/
@GetMapping("/getIndexDetail") @GetMapping("/getIndexDetail")
@ResponseBody @ResponseBody
public R<?> getIndexDetail(String indexCode) { public R<?> getIndexDetail(String indexCode) {
@@ -43,6 +48,11 @@ public class IndexInfoControllerV1 extends BaseController {
indexDetailService.getIndexDetail(indexCode)); indexDetailService.getIndexDetail(indexCode));
} }
/**
* 强制刷新并获取指标详情解释
* @param indexCode
* @return
*/
@GetMapping("/forceRefreshAndGetIndexDetail") @GetMapping("/forceRefreshAndGetIndexDetail")
@ResponseBody @ResponseBody
public R<?> forceRefreshAndGetIndexDetail(String indexCode) { public R<?> forceRefreshAndGetIndexDetail(String indexCode) {
@@ -51,21 +61,14 @@ public class IndexInfoControllerV1 extends BaseController {
indexDetailService.forceRefreshAndGetIndexDetail(indexCode)); indexDetailService.forceRefreshAndGetIndexDetail(indexCode));
} }
@GetMapping("/configIndOnline")
@ResponseBody
public R<?> configIndOnline(String url) throws IOException {
//return R.judge(() -> indexInfo.getOnlineConfigByUrl(url));
return R.ok(indexInfo.getConfigIndOnline());
}
@GetMapping("/getFields") @GetMapping("/getFields")
@ResponseBody @ResponseBody
public R<?> getFields(@RequestParam("fields") String[] fields) { public R<?> getFields(@RequestParam("fields") String[] fields) {
if (fields == null || fields.length == 0) { if (fields == null || fields.length == 0) {
return R.ok(indexInfo); return R.ok(indexInfoConfig);
} }
ObjectNode indexInfoJson = new ObjectMapper().valueToTree(indexInfo); Object indexInfoConfigWithoutProxy = AopProxyUtils.getSingletonTarget(indexInfoConfig);
ObjectNode indexInfoJson = new ObjectMapper().valueToTree(indexInfoConfigWithoutProxy);
Map<String, Object> map = new HashMap<>(); Map<String, Object> map = new HashMap<>();
for (String field : fields) { for (String field : fields) {
map.put(field, indexInfoJson.get(field)); map.put(field, indexInfoJson.get(field));
@@ -73,17 +76,21 @@ public class IndexInfoControllerV1 extends BaseController {
return R.ok(map); return R.ok(map);
} }
/**
* 根据给定 url 获取在线指标配置
* @param url
* @return
*/
@GetMapping("/getConfigIndOnlineByUrl") @GetMapping("/getConfigIndOnlineByUrl")
@ResponseBody @ResponseBody
public R<?> getConfigOnlineByUrl(String url) { public R<?> getConfigOnlineByUrl(String url) {
return R.judge(() -> indexInfo.getOnlineConfigByUrl()); return R.judge(() -> indexInfoConfig.getOnlineConfigByUrl(url));
} }
@GetMapping("/getIndexInfoConfig") @GetMapping("/getIndexInfoConfig")
@ResponseBody @ResponseBody
public R<?> getIndexInfoConfig() { public R<?> getIndexInfoConfig() {
return R.ok(indexInfo); return R.ok(indexInfoConfig);
} }
@GetMapping("/list") @GetMapping("/list")

View File

@@ -1,11 +1,8 @@
package quant.rich.emoney.controller.manage; package quant.rich.emoney.controller.manage;
import java.lang.reflect.Field;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -16,17 +13,11 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
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 com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.controller.common.BaseController; import quant.rich.emoney.controller.common.UpdateBoolServiceController;
import quant.rich.emoney.entity.sqlite.Plan; import quant.rich.emoney.entity.sqlite.Plan;
import quant.rich.emoney.exception.RException; import quant.rich.emoney.exception.RException;
import quant.rich.emoney.interfaces.IQueryableEnum;
import quant.rich.emoney.pojo.dto.LayPageReq; import quant.rich.emoney.pojo.dto.LayPageReq;
import quant.rich.emoney.pojo.dto.LayPageResp; import quant.rich.emoney.pojo.dto.LayPageResp;
import quant.rich.emoney.pojo.dto.R; import quant.rich.emoney.pojo.dto.R;
@@ -35,7 +26,7 @@ import quant.rich.emoney.service.sqlite.PlanService;
@Slf4j @Slf4j
@Controller @Controller
@RequestMapping("/admin/v1/manage/plan") @RequestMapping("/admin/v1/manage/plan")
public class PlanControllerV1 extends BaseController { public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
@Autowired @Autowired
PlanService planService; PlanService planService;
@@ -48,87 +39,42 @@ public class PlanControllerV1 extends BaseController {
@GetMapping("/list") @GetMapping("/list")
@ResponseBody @ResponseBody
public LayPageResp<?> list(LayPageReq<Plan> pageReq) { public LayPageResp<?> list(LayPageReq<Plan> pageReq) {
Page<Plan> planPage = planService.page(pageReq); return super.list(pageReq);
return new LayPageResp<>(planPage);
} }
@GetMapping("/getOne") @GetMapping("/getOne")
@ResponseBody @ResponseBody
public R<?> getOne(String planId) { public R<?> getOne(String planId) {
return super.getOne(planId);
// 如果 planId 是空,说明可能希望新建一个 Plan需要返回默认实例化对象
if (planId == null) {
return R.ok(new Plan());
}
// 否则从数据库取
Plan plan = planService.getById(planId);
return R.judge(plan != null, plan, "无法找到对应 ID 的 Plan");
}
@PostMapping("/updateEnabledStatus")
@ResponseBody
public R<?> updateEnabledStatus(String planId, Boolean enabled) {
if (planService.update(new LambdaUpdateWrapper<Plan>()
.eq(Plan::getPlanId, planId)
.set(Plan::getEnabled, enabled))) {
return R.ok();
}
throw RException.badRequest();
}
@PostMapping("/updateBool")
@ResponseBody
public R<?> updateBool(String planId, String field, Boolean value) {
TableInfo tableInfo = TableInfoHelper.getTableInfo(Plan.class);
try {
Field declaredField = Plan.class.getDeclaredField(field);
Optional<TableFieldInfo> fieldInfo = tableInfo.getFieldList().stream()
.filter(f -> f.getProperty().equals(field))
.findFirst();
if (declaredField.getType().equals(Boolean.class)) {
planService.update(
new UpdateWrapper<Plan>()
.eq("plan_id", planId)
.set(fieldInfo.get().getColumn(), value));
return R.ok();
}
}
catch (Exception e) {}
throw RException.badRequest();
} }
@PostMapping("/save") @PostMapping("/save")
@ResponseBody @ResponseBody
public R<?> save(@RequestBody Plan plan) { public R<?> save(@RequestBody Plan plan) {
if (StringUtils.isNotBlank(plan.getPlanId())) { return super.save(plan);
planService.updateById(plan);
}
else {
planService.save(plan.setPlanId(null));
}
return R.ok();
} }
@PostMapping("/delete") @PostMapping("/delete")
@ResponseBody @ResponseBody
public R<?> delete(String planId) { public R<?> delete(String planId) {
return R.judge(planService.removeById(planId), "删除失败,是否已删除?"); return super.delete(planId);
} }
@PostMapping("/batchOp") @PostMapping("/batchOp")
@ResponseBody @ResponseBody
public R<?> batchOp( public R<?> batchOp(
@RequestParam(value="ids[]", required=true) @RequestParam(value="ids[]", required=true)
String[] ids, String op) { String[] ids, PlanBatchOp op) {
if (Objects.isNull(ids) || ids.length == 0) { if (Objects.isNull(ids) || ids.length == 0) {
throw RException.badRequest("提供的计划 ID 不能为空"); throw RException.badRequest("提供的计划 ID 不能为空");
} }
List<String> idArray = Arrays.asList(ids); List<String> idArray = Arrays.asList(ids);
if (StringUtils.isBlank(op)) { if (op == null) {
// op 为空是删除 // op 为空是删除
throw RException.badRequest("操作类型不能为空");
}
else if (PlanBatchOp.DELETE == op) {
return R.judge( return R.judge(
planService.removeBatchByIds(idArray)); planService.removeBatchByIds(idArray));
} }
@@ -136,23 +82,42 @@ public class PlanControllerV1 extends BaseController {
LambdaUpdateWrapper<Plan> uw = new LambdaUpdateWrapper<>(); LambdaUpdateWrapper<Plan> uw = new LambdaUpdateWrapper<>();
uw.in(Plan::getPlanId, idArray); uw.in(Plan::getPlanId, idArray);
if ("enable".equals(op)) { switch (op) {
case ENABLE:
uw.set(Plan::getEnabled, true); uw.set(Plan::getEnabled, true);
} break;
else if ("disable".equals(op)) { case DISABLE:
uw.set(Plan::getEnabled, false); uw.set(Plan::getEnabled, false);
} break;
else if ("enableOpenDayCheck".equals(op)) { case ENABLE_OPEN_DAY_CHECK:
uw.set(Plan::getOpenDayCheck, true); uw.set(Plan::getOpenDayCheck, true);
} break;
else if ("disableOpenDayCheck".equals(op)) { case DISABLE_OPEN_DAY_CHECK:
uw.set(Plan::getOpenDayCheck, false); uw.set(Plan::getOpenDayCheck, false);
break;
default:
throw RException.badRequest("未知操作");
} }
else { return R.judge(() -> planService.update(uw));
throw RException.badRequest("未识别的操作");
} }
return R.judge(planService.update(uw)); private static enum PlanBatchOp implements IQueryableEnum {
DELETE("删除"),
ENABLE("启用"),
DISABLE("停用"),
ENABLE_OPEN_DAY_CHECK("开启交易日校验"),
DISABLE_OPEN_DAY_CHECK("关闭交易日校验");
private String note;
private PlanBatchOp(String note) {
this.note = note;
}
@Override
public String getNote() {
return note;
}
} }
} }

View File

@@ -13,7 +13,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.controller.common.BaseController; import quant.rich.emoney.controller.common.BaseController;
import quant.rich.emoney.entity.sqlite.Plan;
import quant.rich.emoney.entity.sqlite.ProtocolMatch; import quant.rich.emoney.entity.sqlite.ProtocolMatch;
import quant.rich.emoney.exception.RException; import quant.rich.emoney.exception.RException;
import quant.rich.emoney.pojo.dto.LayPageReq; import quant.rich.emoney.pojo.dto.LayPageReq;

View File

@@ -0,0 +1,122 @@
package quant.rich.emoney.controller.manage;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
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.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 com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.controller.common.UpdateBoolServiceController;
import quant.rich.emoney.entity.sqlite.ProxySetting;
import quant.rich.emoney.exception.RException;
import quant.rich.emoney.pojo.dto.LayPageReq;
import quant.rich.emoney.pojo.dto.LayPageResp;
import quant.rich.emoney.pojo.dto.R;
import quant.rich.emoney.service.sqlite.ProxySettingService;
@Slf4j
@Controller
@RequestMapping("/admin/v1/manage/proxySetting")
public class ProxySettingControllerV1 extends UpdateBoolServiceController<ProxySetting> {
@Autowired
ProxySettingService proxySettingService;
@GetMapping({"", "/", "/index"})
public String index() {
return "/admin/v1/manage/proxySetting/index";
}
@GetMapping("/list")
@ResponseBody
public LayPageResp<?> list(LayPageReq<ProxySetting> pageReq) {
return super.list(pageReq);
}
@GetMapping("/getOne")
@ResponseBody
public R<?> getOne(String id) {
return super.getOne(id);
}
@PostMapping("/save")
@ResponseBody
public R<?> save(@RequestBody ProxySetting proxySetting) {
return super.save(proxySetting);
}
@PostMapping("/delete")
@ResponseBody
public R<?> delete(String id) {
return super.delete(id);
}
@Override
protected
R<?> updateBool(String id, String field, Boolean value) {
return super.updateBool(id, field, value);
}
@PostMapping("/batchOp")
@ResponseBody
public R<?> batchOp(
@RequestParam(value="ids[]", required=true)
String[] ids, ProxySettingBatchOp op) {
if (Objects.isNull(ids) || ids.length == 0) {
throw RException.badRequest("提供的计划 ID 不能为空");
}
List<String> idArray = Arrays.asList(ids);
if (op == null) {
// op 为空是删除
throw RException.badRequest("操作类型不能为空");
}
else if (ProxySettingBatchOp.DELETE == op) {
return R.judge(
proxySettingService.removeBatchByIds(idArray));
}
LambdaUpdateWrapper<ProxySetting> uw = new LambdaUpdateWrapper<>();
uw.in(ProxySetting::getId, idArray);
switch (op) {
case CHECK:
// TODO: 检查连通性
break;
case DISABLE_HTTPS_VERIFY:
uw.set(ProxySetting::getIgnoreHttpsVerification, false);
break;
case ENABLE_HTTP_VERIFY:
uw.set(ProxySetting::getIgnoreHttpsVerification, true);
break;
default:
throw RException.badRequest("未知操作");
}
return R.judge(() -> proxySettingService.update(uw));
}
private static enum ProxySettingBatchOp {
DELETE,
CHECK,
DISABLE_HTTPS_VERIFY,
ENABLE_HTTP_VERIFY
}
@GetMapping("/refreshIpThroughProxy")
@ResponseBody
public R<?> refreshIpThroughProxy() {
return R.ok(
proxySettingService
.refreshIpThroughProxy());
}
}

View File

@@ -0,0 +1,98 @@
package quant.rich.emoney.controller.manage;
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
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 com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.controller.common.UpdateBoolServiceController;
import quant.rich.emoney.entity.sqlite.RequestInfo;
import quant.rich.emoney.exception.RException;
import quant.rich.emoney.pojo.dto.LayPageReq;
import quant.rich.emoney.pojo.dto.LayPageResp;
import quant.rich.emoney.pojo.dto.R;
import quant.rich.emoney.service.sqlite.RequestInfoService;
@Slf4j
@Controller
@RequestMapping("/admin/v1/manage/requestInfo")
public class RequestInfoControllerV1 extends UpdateBoolServiceController<RequestInfo> {
@Autowired
RequestInfoService requestInfoService;
@GetMapping({"", "/", "/index"})
public String index() {
return "/admin/v1/manage/requestInfo/index";
}
@GetMapping("/list")
@ResponseBody
public LayPageResp<?> list(LayPageReq<RequestInfo> pageReq) {
return super.list(pageReq);
}
@GetMapping("/getOne")
@ResponseBody
public R<?> getOne(Integer id) {
return super.getOne(id);
}
@PostMapping("/save")
@ResponseBody
public R<?> save(@RequestBody @NonNull RequestInfo requestInfo) {
return super.save(requestInfo);
}
@PostMapping("/delete")
@ResponseBody
public R<?> delete(String id) {
return super.delete(id);
}
@Override
protected
R<?> updateBool(String id, String field, Boolean value) {
return super.updateBool(id, field, value);
}
@PostMapping("/batchOp")
@ResponseBody
public R<?> batchOp(
@RequestParam(value="ids[]", required=true)
@Valid @NotEmpty String[] ids, @NotNull RequestInfoBatchOp op) {
List<String> idArray = Arrays.asList(ids);
if (op == RequestInfoBatchOp.DELETE) {
return R.judge(getThisService().removeByIds(idArray));
}
LambdaUpdateWrapper<RequestInfo> uw = new LambdaUpdateWrapper<>();
uw.in(RequestInfo::getId, idArray);
switch (op) {
default:
throw RException.badRequest("未知操作");
}
}
private static enum RequestInfoBatchOp {
DELETE,
ENABLE_ANONYMOUS,
DISABLE_ANONYMOUS
}
}

View File

@@ -0,0 +1,94 @@
package quant.rich.emoney.controller.manage;
import java.io.IOException;
import java.util.Objects;
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.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.extension.plugins.pagination.Page;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.controller.common.BaseController;
import quant.rich.emoney.entity.sqlite.StrategyAndPool;
import quant.rich.emoney.exception.RException;
import quant.rich.emoney.pojo.dto.LayPageReq;
import quant.rich.emoney.pojo.dto.LayPageResp;
import quant.rich.emoney.pojo.dto.R;
import quant.rich.emoney.service.sqlite.StrategyAndPoolService;
@Slf4j
@Controller
@RequestMapping("/admin/v1/manage/strategyAndPool")
public class StrategyAndPoolControllerV1 extends BaseController {
@Autowired
StrategyAndPoolService strategyAndPoolService;
@GetMapping({"", "/", "/index"})
public String index() {
System.out.println(strategyAndPoolService.exportPreparedStrategies().toPrettyString());
return "/admin/v1/manage/strategyAndPool/index";
}
@GetMapping("/list")
@ResponseBody
public LayPageResp<?> list(LayPageReq<StrategyAndPool> pageReq) {
Page<StrategyAndPool> planPage = strategyAndPoolService.page(pageReq,
new LambdaQueryWrapper<StrategyAndPool>()
.orderByAsc(StrategyAndPool::getStrategyId, StrategyAndPool::getPoolId));
return new LayPageResp<>(planPage);
}
@GetMapping("/getOne")
@ResponseBody
public R<?> getOne(Integer poolId) {
// 如果 planId 是空,说明可能希望新建一个 Plan需要返回默认实例化对象
if (poolId == null) {
return R.ok(new StrategyAndPool());
}
// 否则从数据库取
StrategyAndPool strategyAndPool = strategyAndPoolService.getById(poolId);
return R.judge(strategyAndPool != null, strategyAndPool, "无法找到对应 ID 的 StrategyAndPool");
}
@PostMapping("/save")
@ResponseBody
public R<?> save(@RequestBody StrategyAndPool strategyAndPool) {
if (Objects.nonNull(strategyAndPool.getPoolId())) {
return R.judge(
strategyAndPoolService.saveOrUpdate(strategyAndPool), "保存失败");
}
throw RException.badRequest("poolId 不允许为空");
}
@PostMapping("/delete")
@ResponseBody
public R<?> delete(Integer poolId) {
return R.judge(strategyAndPoolService.removeById(poolId), "删除失败,是否已删除?");
}
/**
* 导出 prepared_strategies.json
* @throws IOException
*/
@GetMapping("/exportPreparedStrategies")
@ResponseBody
public void exportPreparedStrategies() throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Content-Disposition", "attachment; filename=\"prepared_strategies.json\"");
JsonNode json = strategyAndPoolService.exportPreparedStrategies();
response.getWriter().write(json.toPrettyString());
}
}

View File

@@ -19,6 +19,7 @@ public class AndroidSdkLevelConfig implements IConfig<AndroidSdkLevelConfig> {
public AndroidSdkLevelConfig() { public AndroidSdkLevelConfig() {
androidVerToSdk = new HashMap<>(); androidVerToSdk = new HashMap<>();
androidVerToSdk.put("15", 35);
androidVerToSdk.put("14", 34); androidVerToSdk.put("14", 34);
androidVerToSdk.put("13", 33); androidVerToSdk.put("13", 33);
androidVerToSdk.put("12L", 32); androidVerToSdk.put("12L", 32);

View File

@@ -49,6 +49,11 @@ public class DeviceInfoConfig implements IConfig<DeviceInfoConfig> {
@Slf4j @Slf4j
public static class DeviceInfo { public static class DeviceInfo {
// 持久化在本地的 DeviceInfo 只有三个字段:
// model、deviceType 和 fingerprint
// 其中除 model 和 deviceType 外,其他字段全部从 fingerprint 派生
// 也就是说只要提供 model、deviceType 和 fingerprint 就能创建一个 DeviceInfo 实例
@JsonView(IConfig.Views.Persistence.class) @JsonView(IConfig.Views.Persistence.class)
private String model; private String model;
private String brand; private String brand;
@@ -62,11 +67,12 @@ public class DeviceInfoConfig implements IConfig<DeviceInfoConfig> {
private String buildType; private String buildType;
private String buildTags; private String buildTags;
/**
* 用以匹配 fingerprint 的正则表达式
*/
public static final Pattern PATTERN = Pattern.compile("^(?<brand>.*?)/(?<product>.*?)/(?<device>.*?):(?<versionRelease>.*?)/(?<buildId>.*?)/(?<buildNumber>.*?):(?<buildType>.*?)/(?<buildTags>.*?)$"); public static final Pattern PATTERN = Pattern.compile("^(?<brand>.*?)/(?<product>.*?)/(?<device>.*?):(?<versionRelease>.*?)/(?<buildId>.*?)/(?<buildNumber>.*?):(?<buildType>.*?)/(?<buildTags>.*?)$");
private DeviceInfo() {}
private DeviceInfo() {
}
public DeviceInfo setFingerprint(String fingerprint) { public DeviceInfo setFingerprint(String fingerprint) {
Matcher m = PATTERN.matcher(fingerprint); Matcher m = PATTERN.matcher(fingerprint);
@@ -126,8 +132,8 @@ public class DeviceInfoConfig implements IConfig<DeviceInfoConfig> {
} }
public final String toString() { public final String toString() {
return String.format("Model: %s, Fingerprint: %s", return String.format("Model: %s, DeviceType: %s, Fingerprint: %s",
getModel(), getFingerprint() getModel(), getDeviceType(), getFingerprint()
); );
} }
@@ -139,7 +145,7 @@ public class DeviceInfoConfig implements IConfig<DeviceInfoConfig> {
} }
public int hashCode() { public int hashCode() {
return Objects.hash(getModel(), getFingerprint()); return Objects.hash(getModel(), getDeviceType(), getFingerprint());
} }
} }
} }

View File

@@ -1,38 +0,0 @@
package quant.rich.emoney.entity.config;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import lombok.experimental.Accessors;
import quant.rich.emoney.enums.StockSpan;
@Data
@Accessors(chain=true)
public class IndexInfo {
private List<ParamInfo> paramInfoList = new ArrayList<>();
private String code;
private String name;
private Boolean isCalc;
private List<StockSpan> supportPeriod = new ArrayList<>();
@Data
@Accessors(chain=true)
public static class ParamInfo {
private String name;
private Integer max;
private Integer min;
private Integer defaultValue;
}
}

View File

@@ -1,29 +1,21 @@
package quant.rich.emoney.entity.config; package quant.rich.emoney.entity.config;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jodd.io.FileUtil;
import lombok.AccessLevel;
import lombok.Data; import lombok.Data;
import lombok.Getter;
import lombok.experimental.Accessors; import lombok.experimental.Accessors;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.Response; import okhttp3.Response;
import quant.rich.emoney.client.OkHttpClientProvider; import quant.rich.emoney.client.OkHttpClientProvider;
import quant.rich.emoney.component.LockByCaller; import quant.rich.emoney.component.CallerLockAspect.LockByCaller;
import quant.rich.emoney.interfaces.ConfigInfo; import quant.rich.emoney.interfaces.ConfigInfo;
import quant.rich.emoney.interfaces.IConfig; import quant.rich.emoney.interfaces.IConfig;
import quant.rich.emoney.service.sqlite.RequestInfoService;
import quant.rich.emoney.util.SpringContextHolder;
/** /**
* 指标信息配置,只做运行时管理,不做保存 * 指标信息配置,只做运行时管理,不做保存
@@ -40,11 +32,6 @@ public class IndexInfoConfig implements IConfig<IndexInfoConfig> {
@JsonView(IConfig.Views.Persistence.class) @JsonView(IConfig.Views.Persistence.class)
private JsonNode configIndOnline; private JsonNode configIndOnline;
@Autowired
@JsonIgnore
@Getter(AccessLevel.PRIVATE)
private EmoneyRequestConfig emoneyRequestConfig;
public IndexInfoConfig() {} public IndexInfoConfig() {}
public String getConfigIndOnlineStr() { public String getConfigIndOnlineStr() {
@@ -53,10 +40,13 @@ public class IndexInfoConfig implements IConfig<IndexInfoConfig> {
@LockByCaller @LockByCaller
@JsonIgnore @JsonIgnore
public String getOnlineConfigByUrl() throws IOException { public String getOnlineConfigByUrl(String url) throws IOException {
synchronized (this) { synchronized (this) {
if (SpringContextHolder.getBean(RequestInfoService.class).getDefaultRequestInfo() == null) {
throw new RuntimeException("请先新增请求配置并作为默认配置");
}
Request request = new Request.Builder() Request request = new Request.Builder()
.url(configIndOnlineUrl) .url(url)
.header("Cache-Control", "no-cache") .header("Cache-Control", "no-cache")
.get() .get()
.build(); .build();
@@ -73,6 +63,4 @@ public class IndexInfoConfig implements IConfig<IndexInfoConfig> {
} }
} }
public static ConnectionPool connectionPool = new ConnectionPool(10, 5, TimeUnit.MINUTES);
} }

View File

@@ -16,6 +16,8 @@ public class PlatformConfig implements IConfig<PlatformConfig> {
private String email; private String email;
private String apiToken;
private Boolean isInited; private Boolean isInited;
public PlatformConfig() { public PlatformConfig() {

View File

@@ -1,101 +0,0 @@
package quant.rich.emoney.entity.config;
import java.net.InetSocketAddress;
import java.net.Proxy;
import org.apache.commons.lang3.ObjectUtils;
import com.fasterxml.jackson.annotation.JsonView;
import lombok.Data;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.interceptor.EnumOptionsInterceptor.EnumOptions;
import quant.rich.emoney.interfaces.ConfigInfo;
import quant.rich.emoney.interfaces.IConfig;
import quant.rich.emoney.pojo.dto.IpInfo;
import quant.rich.emoney.util.GeoIPUtil;
import quant.rich.emoney.validator.ProxyConfigValid;
/**
* 独立出来一个代理设置的原因是后续可能需要做一个代理池,这样的话独立配置比较适合后续扩展
*/
@Data
@Accessors(chain = true)
@Slf4j
@ProxyConfigValid
@ConfigInfo(field = "proxy", name = "代理设置", initDefault = true)
public class ProxyConfig implements IConfig<ProxyConfig> {
/**
* 代理类型
*/
@EnumOptions("ProxyTypeEnum")
@JsonView(IConfig.Views.Persistence.class)
private Proxy.Type proxyType = Proxy.Type.DIRECT;
/**
* 代理主机
*/
@JsonView(IConfig.Views.Persistence.class)
private String proxyHost = "";
/**
* 代理端口
*/
@JsonView(IConfig.Views.Persistence.class)
private Integer proxyPort = 1;
/**
* 是否忽略 HTTPS 证书校验
*/
@JsonView(IConfig.Views.Persistence.class)
private Boolean ignoreHttpsVerification = false;
/**
* 通过代理后的 IP不做存储只做呈现
*/
private IpInfo ipInfo;
public void afterBeanInit() {
//refreshIpThroughProxy();
}
public synchronized IpInfo refreshIpThroughProxy() {
ipInfo = GeoIPUtil.getIpInfoThroughProxy(this);
return ipInfo;
}
public ProxyConfig() {}
/**
* 根据配置获取代理
* @return
*/
public Proxy getProxy() {
if (getProxyType() != null && getProxyType() != Proxy.Type.DIRECT) {
return new Proxy(getProxyType(),
new InetSocketAddress(getProxyHost(), getProxyPort()));
}
return Proxy.NO_PROXY;
}
public String getProxyUrl() {
if (ObjectUtils.anyNull(getProxyType(), getProxyHost(), getProxyPort())) {
return null;
}
StringBuilder sb = new StringBuilder();
if (getProxyType() == Proxy.Type.SOCKS) {
sb.append("socks5://");
}
else if (getProxyType() == Proxy.Type.HTTP) {
sb.append("http://");
}
else {
return null;
}
sb.append(getProxyHost()).append(':').append(getProxyPort());
return sb.toString();
}
}

Some files were not shown because too many files have changed in this diff Show More