当收藏夹爆满,人生却原地踏步:破解“万事皆懂,然并卵”的终极方案


知行合一:破解“万事皆懂,然并卵”的时代困局

我们活在一个知识过剩的时代。“收藏夹”早已不堪重负,大脑被各路“干货”填充得满满当当,但身体却仿佛被无形的缰绳捆绑在原地。那句“道理都懂,但还是过不好这一生”的自嘲,与其说是戏谑,不如说是这个时代深沉的集体叹息。

我们与理想人生的距离,并非隔着未知的知识,而是横亘在“知道”与“做到”之间的巨大鸿沟。

明代大儒王阳明一针见血:“知而不行,只是未知。” 这句话为我们提供了终极的诊断书:所有停留在脑海中、未能转化为具体行动的“道理”,都只是虚假的认知幻影。 我们误将“听懂了”当作“学会了”,将“理解了”等同于“拥有了”,而大脑的原始本能又倾向于节约能量、固守旧习。于是,我们在理性的认同与本能的抗拒之间反复拉扯,最终在内耗中败下阵来。

要走出这个困境,与其苦苦寻觅下一个“终极道理”,不如着手为自己搭建一座坚实的桥梁,一座跨越知行鸿沟的行动之桥。这座桥由三个关键的桥墩构成:

一、 微步启动:用“小到可笑”的行动,为改变破冰

行动最大的敌人,是宏大目标带来的心理压迫感。当“我要减重20斤”或“我要读完100本书”这样的念头升起时,大脑的预警系统会瞬间拉响,拖延与逃避便是它最本能的防御。

真正的行动智慧,在于“以小胜大”。请将你的雄心壮志,拆解成一个“小到可笑”、几乎无需动用意志力的“微步动作”。

  • 想健身? 你的第一步不是“冲进健身房锻炼一小时”,而是“穿上运动鞋,在家门口站一分钟”。这个动作的全部意义,在于向你的潜意识宣告:“看,我们已经开始了。”
  • 想阅读? 别强迫自己啃下一整个章节。你的任务是“翻开书,读完眼前这一段话,哪怕只有三行”。让“开始”变得像呼吸一样轻而易举。
  • 想学习新技能? 忘掉那些系统性课程。你的启动仪式是“打开学习软件,看完那个三分钟的介绍视频”。

这正是曾国藩所言“大处着眼,小处下手”的精髓。微步行动如同一根微小的船舵,看似无力,却能撬动整艘巨轮的方向。每一次微小成功的达成,都会释放出一剂名为“多巴胺”的奖赏,为我们注入宝贵的信心。正是这涓滴的积极反馈,汇聚成了驱动我们持续前行的滔滔江河。

二、 正念觉察:做自己心念的主人,而非情绪的奴隶

很多时候,阻碍我们的并非懒惰,而是自动化运行的旧有模式。在疲惫、焦虑或无聊的情绪扳机扣动时,我们便会无意识地滑向即时满足的避风港——刷短视频、吃垃圾食品、冲动消费。

要打破这个循环,我们需要从“无意识的反应”切换到“有意识的选择”。这需要我们修炼“正念觉察”的功夫,在刺激与回应之间,为自己创造一个黄金缓冲地带。

  • 识别情绪扳机: 当你即将滑向旧有模式的瞬间,按下内心的“暂停键”。问自己:“此刻,我内在的感受是什么?是什么触发了这个念头?” 单纯地观察和命名情绪(例如:“哦,这是焦虑感”),就能极大地削弱它对你的控制力。
  • 植入“认知锚点”: 将你学到的道理,转化为特定场景下的“灵魂拷问”。打开购物App前,问自己:“这是‘我想要的’,还是‘我真正需要的’?”在即将发怒时,问自己:“情绪化能解决问题吗?” 这个小小的认知介入,就是理性在潜意识层面打下的楔子。
  • 拥抱不完美,用慈悲代替苛责: 当行动受挫时,最大的内耗来自于自我批判。请用“自我关怀”取代它。告诉自己:“这只是万里长征中的一步,失败是数据的积累,而非人格的审判。我能从中学到什么?” 正如曾子“吾日三省吾身”,其真意并非自我鞭挞,而是温和而持续的自我修正与反馈。

三、 环境设计:让世界成为你的盟友,而非对手

我们往往高估了意志力的神话,却严重低估了环境对行为的塑造力。一个成熟的行动者,从不与环境和人性硬碰硬,而是聪明地设计一个能“助推”自己前行的外部系统。

  • 增加“好行为”的便利性: 想多喝水,就把一个精致的水杯放在显示器旁;想练吉他,就把它从琴箱里解放出来,放在客厅最显眼的位置;想看书,就把书放在你的枕边。让正确的行为变得“毫不费力”。
  • 增加“坏行为”的阻力: 想戒掉零食,第一步就是清空家里的库存;想减少手机干扰,就在工作时段将它放在另一个房间,或为娱乐App设置复杂的密码和使用时限。让错误的行为变得“兴师动众”。
  • 构建社会支持系统: “孟母三迁”的古老智慧至今依然闪光。将你的目标分享给积极、可信赖的伙伴,让他们成为你的“成长合伙人”。加入一个有共同目标的社群,同伴的鼓励和榜样的力量,是你对抗孤独与懈怠最坚实的后盾。

晚清名臣郭嵩焘曾哀叹自己终日埋首书斋,满腹经纶却无法付诸救国之行动,最终“徒然成为纸上孤愤”。这个跨越百年的遗憾,至今仍警醒着我们。

“道理都懂,却过不好这一生”的终极解药,不在于寻找更玄妙的道理,而在于回归最朴素的行动。

从一个微不足道的善意行动开始,用正念觉察照亮内心的迷雾,并聪明地借助环境的力量,我们就能开启一个“认知 → 行为 → 反馈 → 再认知”的良性循环。在这个不断上升的螺旋中,知识不再是冰冷的概念,而是融入血液的本能;成长不再是遥远的彼岸,而是你脚下每一步坚实的印记。

知行合一,并非一个一劳永逸的终点,而是一种日复一日、持续进行的生命状态。当你开始行动的那一刻,无论多么微小,你便已经走在了“过好这一生”的康庄大道上。

愿我们都能成为一个温和而坚定的行动者,在时光的长河里,亲手雕刻出那个更从容、更强大的自己。


最后:如何避免读懂了这篇文章,却依然无动于衷?

读到这里,你或许心潮澎湃,深有共鸣。但最危险的时刻,也恰恰是现在——当你心满意足地准备关掉这个页面,然后……生活照旧。

这本身,就是“知行不一”最讽刺的现场演示。为了不让这篇文章沦为又一个“懂了却没用”的道理,请你与我完成最后一个,也是最关键的一步:一个“一分钟行动契约”。

不要思考,不要犹豫,请在接下来的一分钟内,完成以下三件事:

  1. 锁定一个最小战场: 从你最想改变的无数件事情中,只选一件。不是“健康”,而是“喝水”;不是“学习”,而是“读那本买来很久的书”。把目标缩小到极致。

  2. 定义一个“即刻行动”: 为这个最小战场,设计一个此刻、马上、身体一动就能完成的“微步动作”。它的标准是:毫无难度,甚至有点可笑。

  • 如果你选了“喝水”,你的“即刻行动”是:站起来,去给自己倒一杯水,然后放在手边。
  • 如果你选了“读那本书”,你的“即刻行动”是:走过去,把那本书从书架(或箱子)里拿出来,翻开到第一页,放在你的枕头边或电脑旁。
  • 如果你选了“整理房间”,你的“即刻行动”是:捡起视野范围内的三件垃圾或杂物,扔进垃圾桶或放回原位。
  1. 立即执行,现在!

请立即放下手机或离开电脑屏幕,去完成你刚才定义的那个动作。
对,就是现在。我在这里等你一分钟。

...

欢迎回来。

当你完成那个微不足道的动作时,恭喜你,你已经完成了最艰难的一步——**你将这篇文章从一个“认知层面的信息”,转化成了一个“身体层面的经验”。**

刚才那个起身倒水的你,那个拿起书本的你,已经不再是几分钟前那个仅仅“知道”的你了。你已经是一个“做到”的人,哪怕只做到了万分之一。

**这,就是知行合一真正的起点。**

请记住刚才完成那个微小行动时的感觉。这份微小的成就感,才是这篇文章送给你最宝贵的礼物。从现在起,忘记那些宏大的道理,只专注于你的下一个“一分钟行动”。

真正的改变,从不发生于“恍然大悟”的瞬间,而发生于“身体力行”的此刻。

Transformer 架构详解:写给初学者的入门指南

这是一份写给初学者的 Transformer 架构系统性介绍。我们将用尽可能通俗易懂的语言、恰当的比喻和清晰的结构,来剖析这个当今人工智能领域最重要的模型之一。


想象一下,你在做一篇很长的英文阅读理解。传统的做法(就像旧的 AI 模型 RNN/LSTM)是你一个词一个词地读,读到后面可能会忘记前面的细节。但如果让你先把整篇文章通读一遍,然后在回答问题时,你可以随时回头查看文章的任何部分,并重点关注与问题最相关的句子,效率和准确性是不是就高多了?

Transformer 架构就是后面这种“聪明的读者”。它彻底改变了 AI 处理序列数据(尤其是文本)的方式。

一、核心思想:告别“按顺序”,拥抱“全局视野”

在 Transformer 出现之前,主流的模型如循环神经网络(RNN)和长短期记忆网络(LSTM)都是顺序处理文本的。它们像一个一个地读单词,试图在脑中维持一个“记忆”来理解上下文。

RNN/LSTM 的两大痛点:

  1. 效率低下:必须一个词处理完才能处理下一个,无法并行计算,处理长文本时速度很慢。
  2. 长期依赖问题:当句子很长时,模型很难记住最开始的信息。比如,“我出生在法国……(中间省略一万字)……所以我最擅长的语言是法语。” 模型可能已经忘记了开头的“法国”。

Transformer 的革命性思想:

  1. 并行计算:一次性读取所有单词,就像把整篇文章铺在桌上。
  2. **自注意力机制 (Self-Attention)**:通过一种绝妙的机制,让模型在处理每个单词时,都能“关注”到句子中所有其他单词,并判断它们之间的关联性强弱。

二、宏观架构:一个高效的翻译系统

Transformer 最初是为机器翻译任务设计的。它的经典结构是一个编码器-解码器 (Encoder-Decoder) 架构。

  • **编码器 (Encoder)**:左侧部分。它的任务是“理解”输入的句子。比如输入“I am a student”,编码器会阅读并消化这句话,将其转换成一堆包含丰富语义信息的数字向量(可以理解为“思想精华”)。
  • **解码器 (Decoder)**:右侧部分。它的任务是根据编码器提炼的“思想精华”,生成目标语言的句子。比如生成“我是一个学生”。

编码器和解码器都不是单一的组件,而是由 N 层(原论文中 N=6)完全相同的结构堆叠而成。这就像把一篇文章让 6 个专家轮流阅读和批注,每一层都会在前一层的基础上进行更深入的理解。


三、深入内部:三大关键组件(以编码器为例)

让我们打开一个编码器层(Encoder Layer),看看里面到底有什么。每个编码器层主要由两大部分组成:多头自注意力机制前馈神经网络

1. 准备工作:词嵌入 (Word Embedding) 与位置编码 (Positional Encoding)

在进入编码器之前,输入的文本需要做两步预处理。

  • **词嵌入 (Word Embedding)**:计算机不认识单词,只认识数字。词嵌入就是用一个向量(一串数字)来表示一个单词。例如,“猫”可能被表示为 [0.1, -0.5, 1.2, ...],“狗”可能被表示为 [0.2, -0.4, 1.1, ...]。意思相近的词,它们的向量也更接近。
  • 位置编码 (Positional Encoding):由于 Transformer 一次性看所有词,它本身不知道词的顺序。但语序至关重要(“我打你”和“你打我”完全不同)。位置编码就是给每个词的向量再额外加上一个代表其位置信息的“标签”向量。这样,模型既知道了每个词的意思,也知道了它们的顺序。

2. 核心引擎:自注意力机制 (Self-Attention)

这是 Transformer 最核心、最天才的部分。它让模型知道在理解一个词时,应该重点关注句子中的哪些其他词。

工作原理(Q, K, V类比法):
想象你在图书馆查资料。

  • **Query (Q, 查询)**:你当前正在研究的主题(比如,你想理解句子中的 “it” 这个词)。
  • **Key (K, 键)**:图书馆里每本书的书名或标签(句子中的每一个词都有一个 Key)。
  • **Value (V, 值)**:书本的具体内容(句子中的每一个词也都有一个 Value,通常是它的词嵌入向量)。

过程如下:

  1. 生成 Q, K, V:对于输入句子中的每个词,我们都通过三个不同的权重矩阵,从它的词嵌入向量生成三个新的向量:Query 向量、Key 向量和 Value 向量。
  2. 计算注意力分数:要理解 “it” 这个词 (它的 Q),你需要将它的 Q 向量与句子中所有词的 K 向量进行点积计算。这个得分代表了 “it” 与其他每个词的关联程度。
  3. **归一化 (Softmax)**:将这些分数通过 Softmax 函数转换成 0到1 之间的权重,且所有权重加起来等于1。权重越高的词,说明关联性越强。
  4. 加权求和:将每个词的 V 向量乘以它对应的权重,然后全部加起来。

最终得到的这个加权平均向量,就是 “it” 这个词在当前语境下的全新表示。如果句子是 “The animal didn’t cross the street because it was too tired”,那么 “animal” 这个词的 V 向量会被赋予很高的权重,最终的新向量就会包含大量 “animal” 的信息,模型从而知道 “it” 指的是 “animal”。

3. 升级版:多头注意力机制 (Multi-Head Attention)

如果只用一套 Q, K, V,就好比你只有一个角度去理解句子。但句子的关系是多维度的。比如,“我”和“打”是主谓关系,“打”和“你”是动宾关系。

多头注意力 就是雇佣多个“注意力头”(比如 8 个),让它们各自学习自己的一套 Q, K, V 权重。

  • 头1 可能关注主谓关系。
  • 头2 可能关注代词指代关系。
  • 头3 可能关注形容词修饰关系…

每个头都独立进行一次完整的自注意力计算,得出一个结果向量。最后,我们将这 8 个头的结果拼接起来,再通过一个线性层进行整合。这样,模型就能从多个角度和维度更全面地理解句子。

4. 辅助组件:前馈网络 (Feed-Forward) 和 Add & Norm

  • 前馈神经网络:在多头注意力层之后,每个词的输出向量会再经过一个简单的前馈神经网络。你可以把它看成是一个“加工厂”,对注意力层提炼出的信息进行进一步的非线性变换和加工,增强模型的表达能力。
  • **Add & Norm (残差连接和层归一化)**:
    • Add (残差连接):在每个主要组件(如多头注意力和前馈网络)的输出上,都把它加上该组件的输入。这相当于走了一条“捷径”,保证了原始信息不会在多层处理中丢失,极大地稳定了训练过程。
    • **Norm (层归一化)**:对每个残差连接后的输出进行归一化,使其数据分布更加稳定,好比是统一了度量衡,让模型训练起来更快、更稳定。

四、解码器 (Decoder) 的特殊之处

解码器与编码器结构非常相似,但有两点关键不同:

  1. 带掩码的自注意力 (Masked Self-Attention):解码器在生成译文时,是逐词生成的。在预测第 3 个词时,它只能看到已经生成的第 1、2 个词,不能偷看后面的正确答案。这个“掩码”机制就是用来遮盖未来信息的。
  2. 编码器-解码器注意力 (Encoder-Decoder Attention):这是解码器层中的第二个注意力层。它的 Q 来自解码器自身(前一层的输出),但 K 和 V 来自编码器的最终输出。这一步是解码器“查阅”原始句子“思想精华”的过程。比如,在翻译到某个动词时,它会去关注原始句子中的主语和宾语,以确保翻译的准确性。

五、简单实现思路 (以 PyTorch 为例)

对于初学者,无需从零手写所有数学细节。可以利用深度学习框架中封装好的模块来搭建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import torch
import torch.nn as nn

# 1. 关键模块
# 词嵌入
embedding = nn.Embedding(vocab_size, d_model)
# 多头注意力 (包含了Q,K,V的生成和计算)
multihead_attn = nn.MultiheadAttention(embed_dim=d_model, num_heads=8)
# 前馈网络
feed_forward = nn.Sequential(
nn.Linear(d_model, ff_hidden_dim),
nn.ReLU(),
nn.Linear(ff_hidden_dim, d_model)
)
# 层归一化
layer_norm = nn.LayerNorm(d_model)

# 2. 搭建一个编码器层
class EncoderLayer(nn.Module):
def __init__(self):
super().__init__()
# ... 初始化上面的模块

def forward(self, x, mask):
# 多头注意力 + Add & Norm
attn_output, _ = self.multihead_attn(x, x, x, attn_mask=mask)
x = self.layer_norm(x + attn_output)

# 前馈网络 + Add & Norm
ff_output = self.feed_forward(x)
x = self.layer_norm(x + ff_output)

return x

# 3. 搭建完整的 Transformer
class Transformer(nn.Module):
def __init__(self):
super().__init__()
# ...
# 实例化 N 个编码器层
self.encoder_layers = nn.ModuleList([EncoderLayer() for _ in range(N)])
# 实例化 N 个解码器层
self.decoder_layers = nn.ModuleList([DecoderLayer() for _ in range(N)])
# ...

def forward(self, src, tgt, ...):
# 1. 对 src (源句子) 进行词嵌入和位置编码
# 2. 将结果送入编码器栈
# 3. 对 tgt (目标句子) 进行词嵌入和位置编码
# 4. 将编码器输出和处理过的 tgt 送入解码器栈
# 5. 最终通过一个线性层和 Softmax 得到预测的下一个单词的概率
# ...

对于初学者,最好的学习方式是阅读并运行一份带有详细注释的实现代码,例如 PyTorch 官方的 Transformer 教程


六、总结与展望

Transformer 的成功关键:

  • 自注意力机制:实现了对全局上下文的有效建模。
  • 并行计算能力:极大地提高了训练和推理效率,使得处理海量数据和构建超大规模模型成为可能。

正是因为这两个特点,Transformer 不仅仅局限于机器翻译,它已经成为现代 AI 的基石。

  • BERT 系列模型使用 Transformer 的编码器进行语言理解。
  • GPT 系列模型(包括 ChatGPT)使用 Transformer 的解码器进行文本生成。
  • DALL-E, Midjourney 等图像生成模型,也将图像块(patches)视为一种“单词”,用 Transformer 来理解和生成图片。

希望这份介绍能帮你打开 Transformer 的大门。它初看可能有些复杂,但只要理解了其核心的“全局视野”和“自注意力”思想,其他部分就会变得顺理成章。


更新网站ssl证书导致java httpclient请求出错的问题

错误

httpClient.executeMethod(method)出错如下:

1
2
3
4
5
javax.net.ssl.SSLHandshakeException: 
sun.security.validator.ValidatorException:
PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target

原因

由于Mozilla更新了其根证书信任策略,即对于全球所有CA的可信根证书生成后最少15年更换一次,超过时间的可信根将会逐步被Mozilla停止信任,因此Digicert的部分老根证书将会在2023年07月01日左右逐步升级为Digicert Global Root G2。

也就是说新证书的根证书变了。我的老java应用的jre带的security/cacerts没有自带Digicert Global Root G2

解决方法

从浏览器导出”Digicert Global Root G2.crt”,然后导入到用到的java jre中:

1
keytool -importcert -file '/pathto/DigiCert Global Root G2.crt' -alias mykey1 -keystore '/pathto/jre/lib/security/cacerts' -storepass changeit

然后重启java应用即可。

freessl.cn 申请的免费证书也有类似的问题

只是根证书改为:TrustAsia ECC DV TLS CA G3

参考

js实现羽毛球比赛中的八人转和多人转

羽毛球爱好者熟知的八人转,就是N个人轮转进行双打比赛,大家的机会均等、比较公平。一轮打下来的输赢积分较能客观反映实际。

八人转基本规则就是:每人和其他人都组队搭档一次,每队至少上场一次,各人轮换上场,每人上场次数要相同。

编程实现N人转对阵编排的算法思路:
1、找出所有的组队,即N个人中取2人的组合C
2、所有组队两两对阵比赛,即C组队中取2对的组合,但要去除人员冲突的对阵(自己不能和自己打),剩下的对阵仍然可能太多,人多了不可能都打一遍
3、为了公平轮换,只要找上场最少的人和队优先打即可
4、每队都上场一次后,每人上场次数一样时就可以结束轮转,也可以继续打更多局,但总要在每人上场次数一样时结束。

按照上面的思路,用js实现的算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
//组合可能的搭档/组队
function combo(N) {
let pairs = []
for (let a = 1; a <= N; a++) {//从1开始,好看一些
for (let b = a + 1; b <= N; b++) {
pairs.push([a, b, 0])//a和b搭档:[a, b, 上场次数]
}
}
return pairs
}
function isConflict(A, B) {//判断两个组队人员是否冲突
return A == B || A[0] == B[0] || A[0] == B[1] || A[1] == B[0] || A[1] == B[1]
}

//匹配可能的对局
function match(pairs) {
let vs = [], P = pairs.length
for (let i = 0; i < P; i++) {
let A = pairs[i]
for (let j = i + 1; j < P; j++) {
let B = pairs[j]
if (isConflict(A, B)) continue//跳过冲突的对局
vs.push([A, B])//A队和B队对局/对打v:[A,B]
}
}
return vs
}

//N人转,至少打M局的对阵编排
//公平轮转:每人和其他人都搭档一次,每队至少上场一次,各人轮换上场,每人上场次数要相同
function main(N, M) {
if (N < 4) return console.error(`人数不足(${N}<4)`)
if (N > 20) return console.error(`人数太多啦!`)
let plays = new Array(N).fill(0)//记录玩家上场次数
function tires(v) {//计算候选对局的疲劳度
let A = v[0], B = v[1]
return (A[2] + 1) * (plays[A[0] - 1] + plays[A[1] - 1]) + (plays[B[0] - 1] + plays[B[1] - 1]) * (B[2] + 1)
}
let pairs = combo(N)//获取可能的组队
let allvs = match(pairs)//获取所有的对局
let vs = []//对阵上场次序数组
console.log(`${N}人,${pairs.length}队,${M>0?('打'+M+'局'):''}对阵:`)
for (let i = 0; allvs.length > 0 ; i++) {
let v = allvs.shift()//取第一对上场
let A = v[0], B = v[1]//更新对阵参与者
A[2]++, plays[A[0] - 1]++, plays[A[1] - 1]++
B[2]++, plays[B[0] - 1]++, plays[B[1] - 1]++
console.log(`${i + 1}. (${A[0]},${A[1]}) x (${B[0]},${B[1]})`)
vs.push(v)
if (!M || i+1 >= M){
if (pairs.every(p => p[2]>0)){//每队都上场过
if (plays.every(c => c==plays[0])) break//每人上场次数都一样
}
}
allvs = allvs.sort((a, b) => tires(a) - tires(b))//把最少上场的排到第一位
}
console.log(`每人上场${plays[0]}次.\n`)
return vs
}

// 试一下
main(3),main(4),main(5)
main(6),main(6, 15)
main(7),main(7, 21)
main(8),main(8, 16),main(8, 18)
main(9),main(9, 27)
main(10),main(100)

改写成一个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
//N人转对阵编排
class CMatch {
#N //人数
#plays //每人上场次数
#pairs //所有搭档组合
#allvs //所有可能对局

constructor(N) {
this.#N = N;
}

play(M) {//至少M局的对阵编排,不指定M则按最少局数编排
const N = this.#N
if (N < 4) return console.error(`人数不足(${N}<4)`)
if (N > 20) return console.error(`人数太多啦!`)
let plays = this.#genPlays(true)//每人上场次数
let pairs = this.#genPairs(true)//获取可能的组队
let allvs = this.#genAllvs(true)//获取所有的对局
let vs = []//对阵上场次序数组
console.log(`${N}人,${pairs.length}队,${M>0?('打'+M+'局'):''}对阵:`)
for (let i = 0; allvs.length > 0 ; i++) {
let v = allvs.shift()//取第一对上场
let A = v[0], B = v[1]//更新对阵参与者
this.#updatePlay(A)
this.#updatePlay(B)
vs.push(v)
console.log(`${i + 1}. (${A[0]},${A[1]}) x (${B[0]},${B[1]})`)
if (!M || i+1 >= M){
if (pairs.every(p => p[2]>0)){//每队都上场过
if (plays.every(c => c==plays[0])) break//每人上场次数都一样
}
}
allvs = allvs.sort((a, b) => this.#calcTires(a) - this.#calcTires(b))//把最少上场的排到第一位
}
console.log(`每人上场${plays[0]}次。\n`)
return this
}

#genPlays(reset) {//生成每人上场次数数组
if (!this.#plays){
this.#plays = new Array(this.#N).fill(0)
}else if (reset){
this.#plays.fill(0)
}
return this.#plays
}

#genPairs(reset) {//可能的搭档组合
const N = this.#N
if (!this.#pairs){
this.#pairs = []
for (let a = 1; a <= N; a++) {//从1开始,好看一些
for (let b = a + 1; b <= N; b++) {
this.#pairs.push([a, b, 0])//a和b搭档:[a, b, 上场次数]
}
}
}else if (reset){
this.#pairs.forEach(p => p[2] = 0)//重置上场次数
}
return this.#pairs
}

#genAllvs(reset) {//可能的对局
if (!this.#allvs || reset){
this.#allvs = []
let pairs = this.#pairs, P = pairs.length
for (let i = 0; i < P; i++) {
let A = pairs[i]
for (let j = i + 1; j < P; j++) {
let B = pairs[j]
if (CMatch.#isConflict(A, B)) continue//跳过冲突的对局
this.#allvs.push([A, B, 0])//A队和B队对局/对打v:[A,B,上场次数,比?分?]
}
}
}
return this.#allvs
}

#updatePlay(A) {//累加A队上场次数
this.#plays[A[0]-1]++, this.#plays[A[1] - 1]++, A[2]++
}

#calcTires(v) {//计算候选对局的疲劳度
let A = v[0], B = v[1], plays = this.#plays
return (A[2] + 1) * (plays[A[0] - 1] + plays[A[1] - 1]) + (plays[B[0] - 1] + plays[B[1] - 1]) * (B[2] + 1)
}

static #isConflict(A, B) {//判断两个组队人员对局是否冲突
return A == B || A[0] == B[0] || A[0] == B[1] || A[1] == B[0] || A[1] == B[1]
}

}

// 测试
new CMatch(4).play()
new CMatch(5).play()
new CMatch(6).play()
new CMatch(7).play().play(21)
new CMatch(8).play().play(16).play(18)
new CMatch(9).play().play(27)
new CMatch(10).play()

阿里云专用网络ECS安装ftp终极解决方案


安装

1
2
sudo yum install vsftpd
sudo systemctl enable vsftpd

添加用户

1
sudo adduser ftpuser/ftppassword

编辑配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sudo vim /etc/vsftpd/vsftpd.conf
listen=YES
listen_ipv6=NO
#listen_port=21
pasv_enable=YES #被动模式
pasv_min_port=10000
pasv_max_port=10100
pasv_address=54.53.52.51 #专用网络ip变成了映射,本机无法知道自己的真实ip地址。所以必须告知,本机,你的ip地址是什么。 https://yq.aliyun.com/articles/608725
pasv_addr_resolve=yes
anonymous_enable=NO
chroot_local_user=YES
#默认chroot_list_file=/etc/vsftpd/chroot_list没有要创建,为空即可
allow_writeable_chroot=YES
local_root=/home/ftpuser
userlist_enable=YES
userlist_deny=NO
#当userlist_enable=YES时,userlist_deny=NO时:user_list是一个白名单,里面只添加ftpuser,其余默认的去掉
#ftpusers不受任何配制项的影响,它总是有效,它是一个黑名单!https://blog.csdn.net/bluishglc/article/details/42273197

启动

1
sudo systemctl restart vsftpd

开放端口

1
防火墙和安全组开放端口:20-21,10000-10100

亲测按此配置之后,ftp主动和被动模式都正常传输,filezilla等ftp工具可以正常使用,curl、wget/wput等命令行工具也能用。

使用Spring Security实现OAuth2、JWT、SSO等(笔记)

参考资料

常见问题

一、SESSION冲突

“org.springframework.security.authentication.BadCredentialsException: Could not obtain access token, Caused by: org.springframework.security.oauth2.common.exceptions.InvalidRequestException: Possible CSRF detected - state parameter was required but no state could be found”

错误原因

在同一个域名下授权服务器和资源服务器的Cookie名都是JSESSIONID,导致在跳转到授权服务器后将资源服务器的Cookie覆盖了,再次跳转回去时授权服务器的Cookie对资源服务器无效,再次跳转到登录页面,该动作一直重复,导致授权失败。StackOverflow

解决办法

  1. 为授权服务器和资源服务器配置不同的 Cookie 名称: server.servlet.session.cookie.name=AUTH_SESSIONID
  2. 修改应用的 ContextPath:server.servlet.context-path=/auth

Spring boot集成kaptcha图片验证码

kaptcha 是一个图像验证码生成和验证工具,有许多可配置项,可以简单快捷的生成各式各样的验证码,使用起来也很简便。

pom.xml添加依赖

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>kaptcha-spring-boot-starter</artifactId>
<version>1.1.0</version>
</dependency>

application.yaml 添加典型配置

不加用默认也可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kaptcha:
height: 50
width: 200
content:
length: 4
source: abcdefghjklmnopqrstuvwxyz23456789
space: 2
font:
color: black
name: Arial
size: 40
background-color:
from: lightGray
to: white
border:
enabled: true
color: black
thickness: 1

在controller里使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.baomidou.kaptcha.Kaptcha;

@RestController
@RequestMapping("/code")
public class CodeController {
@Autowired
private Kaptcha kaptcha;

@RequestMapping("/image")
void renderImage() {
String code = kaptcha.render();
System.out.println(code);
}

@RequestMapping("/valid")
boolean validImage(@RequestParam String code) {
return kaptcha.validate(code);
}
}

测试

  • 前端访问/code/image即显示验证码图片
  • 前端访问/code/valid?code=xxxx即会返回true表示通过验证,出错表示code错误。

Spring security with thymeleaf

记录spring boot项目使用spring security的核心配置和相关组件。要点:

  1. 支持自定义页面登录
  2. 支持AJAX登录/登出
  3. 支持RBAC权限控制
  4. 支持增加多种认证方式
  5. 支持集群部署(会话共享redis存储)
  6. 支持SessionId放在Header的X-Auth-Token里

项目依赖 pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

相关参考:关于redis 关于thymeleaf

Security配置类 SecurityConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.vote.AuthenticatedVoter;
import org.springframework.security.access.vote.UnanimousBased;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.access.expression.WebExpressionVoter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.session.web.http.HttpSessionIdResolver;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthProviderUsernamePassword authProviderUsernamePassword;
@Autowired
private AuthSuccessHandler authSuccessHandler;
@Autowired
private AuthFailureHandler authFailureHandler;
@Autowired
private ExitSuccessHandler exitSuccessHandler;

@Bean
protected AuthenticationFailureHandler authenticationFailureHandler() {
authFailureHandler.setDefaultFailureUrl("/login?error");
return authFailureHandler;
}

@Bean
protected LogoutSuccessHandler logoutSuccessHandler() {
exitSuccessHandler.setDefaultTargetUrl("/login?logout");
return exitSuccessHandler;
}

private static String[] INGORE_URLS = {"/login", "/error",};

@Override
public void configure(WebSecurity webSecurity) {
webSecurity.ignoring().antMatchers("/static/**");//忽略静态资源
webSecurity.ignoring().antMatchers("/favicon.ico");
}

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeRequests()
.antMatchers(INGORE_URLS).permitAll()
.anyRequest().authenticated()
.accessDecisionManager(accessDecisionManager())//如果不需要权限验证,去掉这句即可
.and()
.formLogin()
.successHandler(authSuccessHandler)
.failureHandler(authFailureHandler)
.loginPage("/login")//.permitAll()
.and()
.logout()
.logoutSuccessHandler(logoutSuccessHandler())//.permitAll()
//.and().rememberMe()
.and().csrf().disable();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authProviderUsernamePassword);
//auth.authenticationProvider(authProvider2);可以增加多个认证方式,比如码验证等
}

@Bean
protected AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<? extends Object>> decisionVoters = Arrays.asList(
new WebExpressionVoter(),
authDecisionVoter(),//new RoleVoter(),
new AuthenticatedVoter());
return new UnanimousBased(decisionVoters);
}

@Bean
protected AuthDecisionVoter authDecisionVoter() {
return new AuthDecisionVoter();
}

@Bean
public HttpSessionIdResolver httpSessionIdResolver() {
return new HeaderCookieHttpSessionIdResolver();
}
}

登录认证类 AuthProviderUsernamePassword.java

AuthenticationProvider提供用户认证的处理方法。如果有多种认证方式,可以实现多个类一并添加到AuthenticationManagerBuilder里即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;

@Component
public class AuthProviderUsernamePassword implements AuthenticationProvider {
@Autowired
AuthUserService authUserService;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
AuthUser userDetails = authUserService.loadUserByUsername(username);
if(userDetails == null){
throw new BadCredentialsException("账号或密码错误");
}
if (!authUserService.checkPassword(userDetails, password)) {
throw new BadCredentialsException("账号或密码不正确");
}
//认证校验通过后,封装UsernamePasswordAuthenticationToken返回
return new UsernamePasswordAuthenticationToken(userDetails, password, authUserService.fillUserAuthorities(userDetails));
}

@Override
public boolean supports(Class<?> authentication) {
return true;
}
}

登录成功处理 AuthSuccessHandler.java

配置于formLogin().successHandler(),可选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;

@Component
public class AuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private RequestCache requestCache = new HttpSessionRequestCache();

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
//登录成功处理,比如记录登录日志
String ip = request.getRemoteAddr();
String targetUrl = "";
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
targetUrl = savedRequest.getRedirectUrl();
}
AuthUser aUser = (AuthUser) authentication.getPrincipal();
System.out.printf("User %s login, ip: %s, url: ", aUser.getUsername(), ip, targetUrl);

if (WebUtils.isAjaxReq(request)) {//ajax登录
response.sendError(200, "success");
return;
}
super.onAuthenticationSuccess(request, response, authentication);
}
}

登录成功处理 AuthFailureHandler.java

配置于formLogin().failureHandler(),可选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;

@Component
public class AuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String uaSummary = WebUtils.getUserAgentSummary(request);
String ip = request.getRemoteAddr();
String username = request.getParameter("username");
System.out.printf("User %s login failed, ip: %s, ua: %s", username, ip, uaSummary);
super.saveException(request, exception);
if (WebUtils.isAjaxReq(request)) {//ajax登录
//为什么用sendError会导致302重定向到login页面?
//--When you invoke sendError it will dispatch the request to /error (it the error handling code registered by Spring Boot. However, Spring Security will intercept /error and see that you are not authenticated and thus redirect you to a log in form.
response.sendError(403, exception.getMessage());
return;
}
response.sendRedirect("login?error");
}
}

登出成功处理 ExitSuccessHandler.java

配置于logout().logoutSuccessHandler(),可选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.stereotype.Component;

@Component
public class ExitSuccessHandler extends SimpleUrlLogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
if (WebUtils.isAjaxReq(request)) {//ajax登录
response.sendError(200, "success");
return;
}
super.onLogoutSuccess(request, response, authentication);
}
}

解析SessionId的类 HeaderCookieHttpSessionIdResolver.java

增加优先从Header里找X-Auth-Token作为SessionId,以适应不支持Cookie的情况。
这个类就是把CookieHttpSessionIdResolver和HeaderHttpSessionIdResolver柔和在一起而已。
对应配置@Bean httpSessionIdResolver。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.session.web.http.CookieHttpSessionIdResolver;
import org.springframework.session.web.http.HeaderHttpSessionIdResolver;
import org.springframework.session.web.http.HttpSessionIdResolver;

public class HeaderCookieHttpSessionIdResolver implements HttpSessionIdResolver {
protected HeaderHttpSessionIdResolver headerResolver = HeaderHttpSessionIdResolver.xAuthToken();
protected CookieHttpSessionIdResolver cookieResolver = new CookieHttpSessionIdResolver();

@Override
public List<String> resolveSessionIds(HttpServletRequest request) {
List<String> sessionIds = headerResolver.resolveSessionIds(request);
if (sessionIds.isEmpty()) {
sessionIds = cookieResolver.resolveSessionIds(request);
}
return sessionIds;
}

@Override
public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {
headerResolver.setSessionId(request, response, sessionId);
cookieResolver.setSessionId(request, response, sessionId);
}

@Override
public void expireSession(HttpServletRequest request, HttpServletResponse response) {
headerResolver.expireSession(request, response);
cookieResolver.expireSession(request, response);
}
}

认证用户类 AuthUser.java

用户实体类,实现UserDetails接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import javax.persistence.Id;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import lombok.Data;

@Data
public class AuthUser implements UserDetails, Serializable {
private static final long serialVersionUID = -1572872798317304041L;

@Id
private Long id;
private String username;
private String password;

private Collection<? extends GrantedAuthority> authorities;

public Collection<? extends GrantedAuthority> fillPerms(List<String> perms) {
String authorityString = StringUtils.collectionToCommaDelimitedString(perms);
authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(authorityString);
return authorities;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

认证用户服务类 AuthUserService.java

提供根据用户名获取用户的方法loadUserByUsername();提供用户的权限fillUserAuthorities()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.joda.time.LocalDateTime;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class AuthUserService implements UserDetailsService {
@Override
public AuthUser loadUserByUsername(String username) throws UsernameNotFoundException {
//读取用户,一般是从数据库读取,这里随便new一个
AuthUser user = new AuthUser();// userDao.findByUsername(username);
user.setId(System.currentTimeMillis());
user.setUsername(username);
user.setPassword(username);
return user;
}

public boolean checkPassword(AuthUser user, String pwd) {
//判断用户密码,这里简单判断相等
if (pwd != null && pwd.equals(user.getPassword())) {
return true;
}
return false;
}

public Collection<? extends GrantedAuthority> fillUserAuthorities(AuthUser aUser) {
//获取用户权限,一般从数据库读取,并缓存。这里随便拼凑
List<String> perms = new ArrayList<>(); //permDao.findPermByUserId(aUser.getId());
LocalDateTime now = LocalDateTime.now();
perms.add("P"+now.getHourOfDay());
perms.add("P"+now.getMinuteOfHour());
perms.add("P"+now.getSecondOfMinute());
return aUser.fillPerms(perms);
}
}

模拟用户示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"id": 1598515192490,
"username": "test",
"password": "test",
"authorities": [{
"authority": "P15"
}, {
"authority": "P59"
}, {
"authority": "P52"
}
]
}

认证入口 AuthControll.java

这里提供loginPage配置的路径”/login”。如果暂不想自定义登录界面,去掉loginPage配置即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class AuthController {
@RequestMapping("/login")//登录入口
String login(String username, Model model) {
model.addAttribute("username", username);
return "login";
}

@RequestMapping("/")//主页
@ResponseBody
Object home(@AuthenticationPrincipal AuthUser currentUser) {
return currentUser;
}

@RequestMapping("/{path}")//测试用
@ResponseBody
Object url1(@PathVariable String path) {
if (path.contains("0")) {//模拟错误
path = String.valueOf(1/0);
}
return path;
}
}

权限验证类 AuthDecisionVoter.java

配置AccessDecisionManager用于自定义权限验证投票器。验证的前提是获取待访问资源(url)相关的权限(getPermissionsByUrl)。验证的方法是,看用户所拥有的权限是否能够匹配url的权限。

Spring security另一种常用的权限控制方式是配置@EnableGlobalMethodSecurity(prePostEnabled = true),在方法上使用@PreAuthorize(“hasPermission(‘PXX’)”)。但用这种方法注解的url,不支持用在thymeleaf模板的sec:authorize-url中。

ps1.thymeleaf 提供了前端判断权限的扩展,参见 thymeleaf-extras-springsecurity & thymeleaf sec:标签的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.FilterInvocation;
import org.springframework.util.StringUtils;

public class RbacDecisionVoter implements AccessDecisionVoter<Object> {
static final String permitAll = "permitAll";

@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}

@Override
public boolean supports(Class<?> clazz) {
return true;
}

@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
if (authentication == null) {
return ACCESS_DENIED;
}

if (attributes != null) {
for (ConfigAttribute attribute : attributes) {
if (permitAll.equals(attribute.toString())) {// skip permitAll
return ACCESS_ABSTAIN;
}
}
}

String requestUrl = ((FilterInvocation) object).getRequestUrl();// 当前请求的URL
Collection<ConfigAttribute> urlPerms = getPermissionsByUrl(requestUrl);// 能访问URL的权限
if (urlPerms == null || urlPerms.isEmpty()) {
return ACCESS_ABSTAIN;
}

int result = ACCESS_ABSTAIN;
Collection<? extends GrantedAuthority> userAuthorities = authentication.getAuthorities(); // 当前用户的权限
for (ConfigAttribute attribute : urlPerms) {
String urlPerm = attribute.getAttribute();
if (StringUtils.isEmpty(urlPerm)) {
continue;
}

result = ACCESS_DENIED;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : userAuthorities) {
if (urlPerm.equals(authority.getAuthority())) {
return ACCESS_GRANTED;
}
}
}
return result;
}

Collection<ConfigAttribute> getPermissionsByUrl(String url) {
// 获取url的访问权限,一般从数据库读取,并缓存。这里随便拼凑
if ("/".equals(url)) {
return null;//根路径不限权
}
String n1 = url.substring(url.length()-1);
String n2 = url.substring(url.length()-2);
return SecurityConfig.createList("P"+n1, "P"+n2);
}
}

自定义登录界面 login.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!DOCTYPE html>
<html lang="zh" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>登录</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"/>
<link rel="stylesheet" href="//cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css"/>
<style type="text/css">
body{padding-top:40px; padding-bottom:40px; background-color:#eee;}
.form-signin{max-width:330px; padding:15px; margin:0 auto;}
</style>
</head>
<body>
<div id="root" class="container">
<form class="form-signin" method="post" th:action="@{/login}">
<h2 class="form-signin-heading">请登录</h2>
<div th:if="${param.logout}" class="alert alert-success" role="alert"><span>您已退出登录</span></div>
<div th:if="${param.error}" class="alert alert-danger" role="alert"><span th:utext="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}">密码错误</span></div>
<p>
<label for="username" class="sr-only">用户账号:</label>
<input type="text" id="username" name="username" class="form-control" placeholder="请输入账号" required autofocus>
</p>
<p>
<label for="password" class="sr-only">用户密码:</label>
<input type="password" name="password" class="form-control" placeholder="请输入密码" required>
</p>
<button class="btn btn-lg btn-primary btn-block" type="submit">确定</button>
</form>
</div>
</body>
</html>

自定义错误信息 CustomErrorAttributes.java

403-没有权限、404-找不到页面等所有错误和异常,都会被SpringBoot默认的BasicErrorController处理。如果有需要,可定制ErrorAttributes。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.Map;
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.WebRequest;

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(WebRequest webRequest, boolean includeStackTrace) {
Map<String, Object> errorAttributes = super.getErrorAttributes(webRequest, includeStackTrace);
errorAttributes.put("code", errorAttributes.getOrDefault("status", 0));//自定义code属性
Throwable error = super.getError(webRequest);
if (error != null && error.getMessage() != null) {
String message = (String)errorAttributes.getOrDefault("message", "");
if (!message.equals(error.getMessage())) {
errorAttributes.put("message", message+" "+error.getMessage());//增强message属性
}
}
return errorAttributes;
}
}

非浏览器访问(produces=”text/html”)出错时,返回json数据,示例:

1
2
3
4
5
6
7
8
{
"timestamp": "2020-08-27T09:05:11.178+0000",
"status": 500,
"error": "Internal Server Error",
"message": "/ by zero",
"path": "/demo/015",
"code": 500
}

浏览器访问(produces=”text/html”)出错时,返回html页面。

自定义错误页面 error/4xx.html

SpringBoot默认的Whitelabel Error Page需要定制,只要把错误页面模板放在error路径下即可。模板中可使用上述ErrorAttributes中的字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="zh" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"/>
<link rel="stylesheet" href="//cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css"/>
</head>
<body>
<div id="root" class="container">
<div class="main">
<br/><h2 class="text-center"><span th:text="${status}">404</span>-<span th:text="${error}">Not Found</span></h2><br/>
<p class="text-center" th:if="${message}"><span th:text="${message}"></span></p>
<p class="text-center" th:if="${exception}"><span th:text="${exception}"></span></p>
<p class="text-center"><a class="btn btn-primary" th:href="@{'/'}">Home</a></p>
</div>
</div>
</body>
</html>

自定义错误页面 error/5xx.html

类似5xx.html,略。

Spring boot mail with thymeleaf template

发送邮件是网站必备的功能,如注册验证,忘记密码或者是给用户发送通知信息。早期我们使用JavaMail相关api来写发送邮件。后来spring推出了JavaMailSender简化了邮件发送的过程,再之后springboot对此进行了封装。

复杂的邮件内容一般使用html,thymeleaf模板可以简化html的生成。

pom.xml

1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

application.propertis

1
2
3
4
5
6
7
8
9
10
11
12
spring.mail.host=smtp.exmail.qq.com
spring.mail.username=noreply@qyqq.com
spring.mail.password=password123456
spring.mail.default-encoding=UTF-8
#spring.mail.properties.mail.smtp.starttls.enable=true
#spring.mail.properties.mail.smtp.starttls.required=true
#spring.mail.properties.mail.smtp.socketFactory.port=465
#spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
#spring.mail.properties.mail.smtp.ssl.trust=smtp.exmail.qq.com
#spring.mail.properties.mail.smtp.connectiontimeout=30000
#spring.mail.properties.mail.smtp.timeout=30000
#spring.mail.properties.mail.smtp.writetimeout=20000

MailService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

@Service
public class MailService {
@Value("${spring.mail.username}")//from address must be same as authorization user
String mailFrom;

@Autowired
JavaMailSender mailSender;

public void sendHtml(String mailTo, String subject, String html) throws MessagingException{
MimeMessage mime = mailSender.createMimeMessage();
MimeMessageHelper mail = new MimeMessageHelper(mime);
mail.setFrom(mailFrom);
mail.setTo(mailTo.split(";"));//支持多个接收者
mail.setSubject(subject);
mail.setText(html, true);
mailSender.send(mime);
}
}

NotifyService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

@Service
public class NotifyService {
private static final String MAIL_TPL_NOTIFY = "mail/notify";//邮件模板.html
@Autowired
private MailService mailService;
@Autowired
private TemplateEngine templateEngine;

public void sendNotify(String mailTo, String subject, Context context) {
new Thread(() -> {//开启线程异步发送邮件
try {
String html = templateEngine.process(MAIL_TPL_NOTIFY, context);
mailService.sendHtml(mailTo, subject, html);
//TODO: 发送成功
} catch (Exception e) {
//TODO: 发送失败
e.printStackTrace();
}
}).start();
}
}

Spring boot开发中“积累[鸡肋]”的工具类

StringUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.security.MessageDigest;
import java.util.Date;
import java.util.Formatter;
import java.util.UUID;
import org.springframework.lang.Nullable;
import org.springframework.util.DigestUtils;

public class StringUtils extends org.springframework.util.StringUtils {

public static boolean equals(@Nullable String str1, @Nullable String str2) {
if (str1 == null || str2 == null) {
return (str1 == null && str2 == null);
}
return str1.equals(str2);
}

public static boolean isBlank(@Nullable String string) {
if (isEmpty(string)) {
return true;
}
for (int i = 0; i < string.length(); i++) {
if (!Character.isWhitespace(string.charAt(i))) {
return false;
}
}
return true;
}

public static boolean isNotBlank(@Nullable String string) {
return !StringUtils.isBlank(string);
}

public static boolean isBlank(@Nullable Integer id) {
return id == null || id == 0;
}

public static boolean isNotBlank(@Nullable Integer id) {
return id != null && id != 0;
}

public static String[] split(@Nullable String string, @Nullable String delimiter, int limit) {
if (isEmpty(string) || delimiter == null || limit == 1) {
return new String[]{string};
}
return string.split(delimiter, limit);
}

/* @return uuid string(32) */
public static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}

private static final String[] chars = new String[] { "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l",
"m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6",
"7", "8", "9", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R",
"S", "T", "U", "V", "W", "X", "Y", "Z" };
/* gen short unique id(8 chars) */
public static String unid8() {
return StringUtils.unid8(null);
}
private static String unid8(String uuid32) {
StringBuffer shortBuffer = new StringBuffer();
if (StringUtils.isBlank(uuid32)) {
uuid32 = StringUtils.uuid();
}
for (int i = 0; i < 8; i++) {
String str = uuid32.substring(i * 4, i * 4 + 4);
int x = Integer.parseInt(str, 16);
shortBuffer.append(StringUtils.chars[x % 0x3E]);
}
return shortBuffer.toString();
}
/* gen unique id(20 chars) that has timestamp */
public static String unid20() {
long ts = new Date().getTime();
return String.format("%d_%s", ts, StringUtils.unid8());
}

/* MD5, null as empty str. */
public static String MD5(String str) {
if (str == null) {
str = "";
}
return DigestUtils.md5DigestAsHex(str.getBytes());
}

/* SHA-1, null as empty str. */
public static String SHA1(String str) {
if (str == null) {
return "";
}
try {
MessageDigest crypt = MessageDigest.getInstance("SHA-1");
crypt.reset();
crypt.update(str.getBytes("UTF-8"));
return byteToHex(crypt.digest());
} catch (Exception e) {
e.printStackTrace();
}
return str;
}
private static String byteToHex(final byte[] hash) {
Formatter formatter = new Formatter();
for (byte b : hash) {
formatter.format("%02x", b);
}
String result = formatter.toString();
formatter.close();
return result;
}

public static String urlDecode(String raw) {
try {
return URLDecoder.decode(raw, "UTF-8");
} catch (UnsupportedEncodingException uee) {
throw new IllegalStateException(uee); // can't happen
}
}

public static String urlEncode(String str) {
try {
return URLEncoder.encode(str, "UTF-8");
} catch (UnsupportedEncodingException uee) {
throw new IllegalStateException(uee); // can't happen
}
}

public static String urlEncode(String str, String en) {
try {
return URLEncoder.encode(str, en);
} catch (UnsupportedEncodingException uee) {
throw new IllegalStateException(uee); // can't happen
}
}

public static String convert(String str, String from, String to) {
try {
str = new String(str.getBytes(from), to);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return str;
}
}

NumberUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.springframework.lang.Nullable;

public class NumberUtils extends org.springframework.util.NumberUtils {
public static <T extends Number> boolean equals(@Nullable T a, @Nullable T b) {
if (a == null || b == null) {
return (a == null && b == null);
}
return a.equals(b);
}

public static <T extends Number> T parse(String text, Class<T> targetClass, T defaultValue) {
if (text == null || text.equals("")) {
return defaultValue;
}
try {
return NumberUtils.parseNumber(text, targetClass);
}catch(Exception e){
e.printStackTrace();
}
return defaultValue;
}
public static <T extends Number> T parse(String text, Class<T> targetClass) {
return NumberUtils.parse(text, targetClass, null);
}
public static Integer parseInt(String text) {
return NumberUtils.parse(text, Integer.class);
}
public static Long parseLong(String text) {
return NumberUtils.parse(text, Long.class);
}
}

DateTimeUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.chrono.ChronoLocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.Locale;
import org.springframework.lang.Nullable;

public class DateTimeUtils {
public static final String DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss";

public static int compare(Date theDate, Date anotherDate) {
if (theDate == null) {
theDate = new Date();
}
if (anotherDate == null) {
anotherDate = new Date();
}
return theDate.compareTo(anotherDate);
}

public static int compare(ChronoLocalDateTime<?> theDate, ChronoLocalDateTime<?> anotherDate) {
if (theDate == null) {
theDate = LocalDateTime.now();
}
if (anotherDate == null) {
anotherDate = LocalDateTime.now();
}
return theDate.compareTo(anotherDate);
}

public static Date toDate(LocalDateTime localDateTime) {
if (localDateTime == null) return null;
return Date.from(localDateTime.toInstant(ZoneOffset.of("+8")));
}

public static LocalDateTime toLocalDateTime(Date date) {
if (date == null) return null;
return fromMilli(date.getTime());
}

public static Date parseDate(@Nullable String string) {
SimpleDateFormat format = new SimpleDateFormat(DATETIME_PATTERN, Locale.CHINA);
try {
return format.parse(string);
} catch (Exception e) {
return null;
}
}
public static LocalDateTime parseLocalDateTime(@Nullable String string) {
DateTimeFormatter format = DateTimeFormatter.ofPattern(DATETIME_PATTERN, Locale.CHINA);
try {
return LocalDateTime.parse(string, format);
} catch (Exception e) {
return null;
}
}

public static String formatDateTime(LocalDateTime dt) {
if (dt == null) return null;

DateTimeFormatter format = DateTimeFormatter.ofPattern(DATETIME_PATTERN, Locale.CHINA);
return dt.format(format);
}

public static LocalDateTime fromSecond(long second, ZoneOffset offset) {
if (offset == null) {
offset = ZoneOffset.of("+8");
}
LocalDateTime localDateTime = LocalDateTime.ofEpochSecond(second, 0, offset);
return localDateTime;
}
public static LocalDateTime fromSecond(long second) {
return DateTimeUtils.fromSecond(second, null);
}

public static LocalDateTime fromMilli(long ts, ZoneOffset offset) {
if (offset == null) {
offset = ZoneOffset.of("+8");
}
Instant instant = Instant.ofEpochMilli(ts);
LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, offset);
return localDateTime;
}
public static LocalDateTime fromMilli(long ts) {
return DateTimeUtils.fromMilli(ts, null);
}

//返回世纪秒
public static long secondsOf(ChronoLocalDateTime<?> ldt, ZoneOffset offset) {
if (ldt == null) {
return 0;
}
if (offset == null) {
offset = ZoneOffset.of("+8");
}
long second = ldt.toEpochSecond(offset);
return second;
}
public static long secondsOf(ChronoLocalDateTime<?> ldt) {
return DateTimeUtils.secondsOf(ldt, null);
}

//返回世纪毫秒
public static long micsecondsOf(ChronoLocalDateTime<?> ldt, ZoneOffset offset) {
if (ldt == null) {
return 0;
}
if (offset == null) {
offset = ZoneOffset.of("+8");
}
long mic = ldt.toInstant(offset).toEpochMilli();
return mic;
}
public static long micsecondsOf(ChronoLocalDateTime<?> ldt) {
return DateTimeUtils.micsecondsOf(ldt, null);
}
}

JSON.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

public class JSON {
@SuppressWarnings("unchecked")
public static Map<String, Object> parse(String jsonStr) {
Map<String, Object> map = new HashMap<>();
try {
map = (Map<String, Object>) JSON.parse(jsonStr, map.getClass());
} catch (Exception e) {
e.printStackTrace();
map = null;
}
return map;
}

public static <T> T parse(String jsonStr, Class<T> toClass) {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(jsonStr, toClass);
} catch (JsonParseException e) {
e.printStackTrace();
} catch (JsonMappingException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

public static <T extends Object> T parse(String jsonStr, TypeReference<T> type) {
try {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
return mapper.readValue(jsonStr, type);
} catch (JsonParseException e) {
e.printStackTrace();
} catch (JsonMappingException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

public static String stringify(Object obj) {
try {
ObjectMapper mapper = new ObjectMapper();
//https://howtoprogram.xyz/2017/12/30/serialize-java-8-localdate-jackson/
mapper.registerModule(new JavaTimeModule());
//mapper.disable(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS);
return mapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}
}

XmlUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;

public class XmlUtils {

@SuppressWarnings("unchecked")
public static Map<String, String> parse(String xml){
Map<String, String> map = new HashMap<>();
return (Map<String, String>) XmlUtils.parse(xml, map.getClass());
}

public static <T> T parse(String xml, Class<T> toClass) {
try {
XmlMapper xmlMapper = new XmlMapper();
return xmlMapper.readValue(xml, toClass);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

public static String stringify(Object obj, String root) {
XmlMapper xmlMapper = new XmlMapper();
try {
ObjectWriter writer = xmlMapper.writer();
if (root != null && !"".equals(root)) {
writer = writer.withRootName(root);
}
return writer.writeValueAsString(obj);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}
}

BeanUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import java.beans.PropertyDescriptor;
import java.util.HashSet;
import java.util.Set;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.util.StringUtils;

public class BeanUtils extends org.springframework.beans.BeanUtils {
/* copy source properties(not null) to target */
public static void copyPropertiesNotNull(Object source, Object target) {
String[] ignoreProperties = BeanUtils.getNullProperties(source);
BeanUtils.copyProperties(source, target, ignoreProperties);
}

/* copy source properties(not empty) to target */
public static void copyPropertiesNotEmpty(Object source, Object target) {
String[] ignoreProperties = BeanUtils.getEmptyProperties(source);
BeanUtils.copyProperties(source, target, ignoreProperties);
}

/* get object's null properties */
public static String[] getNullProperties(Object obj) {
BeanWrapper bean = new BeanWrapperImpl(obj);
PropertyDescriptor[] descriptors = bean.getPropertyDescriptors();
Set<String> properties = new HashSet<>();
for (PropertyDescriptor property : descriptors) {
String propertyName = property.getName();
Object propertyValue = bean.getPropertyValue(propertyName);
if (propertyValue == null) {
properties.add(propertyName);
}
}
return properties.toArray(new String[0]);
}

/* get object's empty properties */
public static String[] getEmptyProperties(Object obj) {
BeanWrapper bean = new BeanWrapperImpl(obj);
PropertyDescriptor[] descriptors = bean.getPropertyDescriptors();
Set<String> properties = new HashSet<>();
for (PropertyDescriptor property : descriptors) {
String propertyName = property.getName();
Object propertyValue = bean.getPropertyValue(propertyName);
if (StringUtils.isEmpty(propertyValue)) {
properties.add(propertyName);
}
}
return properties.toArray(new String[0]);
}
}

WebUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import java.net.InetAddress;
import java.net.UnknownHostException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

public class WebUtils extends org.springframework.web.util.WebUtils {
/* 获取request对象 */
public static HttpServletRequest getRequest() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) return null;

return ((ServletRequestAttributes) requestAttributes).getRequest();
}

/* 获取Response对象 */
public static HttpServletResponse getResponse() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) return null;

return ((ServletRequestAttributes) requestAttributes).getResponse();
}

public static boolean isAjaxReq(HttpServletRequest req) {
//使用request.header检测是否为AJAX请求
String contentTypeHeader = req.getHeader("Content-Type");
String acceptHeader = req.getHeader("Accept");
String xRequestedWith = req.getHeader("X-Requested-With");
return ((contentTypeHeader != null && contentTypeHeader.contains("application/json"))
|| (acceptHeader != null && acceptHeader.contains("application/json"))
|| (acceptHeader != null && !acceptHeader.contains("text/html"))
|| "XMLHttpRequest".equalsIgnoreCase(xRequestedWith));
}

// 根据网卡取本机配置的IP
public static String getServerIpAddr() {
try {
InetAddress inet = InetAddress.getLocalHost();
return inet.getHostAddress();
} catch (UnknownHostException e) {
e.printStackTrace();
}
return null;
}

public static String getWebBase() {//Web访问路径
HttpServletRequest request = getRequest();
if (request != null) {
return String.format("%s://%s:%s%s/", request.getScheme(), request.getServerName(), request.getServerPort(), request.getContextPath());
}
return "";
}
}

QRCodeUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.Hashtable;
import javax.imageio.ImageIO;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;

public class QRCodeUtils {
private static final String FORMAT_PNG = "JPG";//"PNG";
// 二维码尺寸
private static final int QRCODE_SIZE = 300;

private static final int BLACK = 0xFF000000;//用于设置图案的颜色
private static final int WHITE = 0xFFFFFFFF; //用于背景色

public static byte[] genQRCodeImageBytes(String str) {
return QRCodeUtils.genQRCodeImageBytes(str, null, QRCODE_SIZE, QRCODE_SIZE);
}

public static byte[] genQRCodeImageBytes(String str, String logoPath) {
return QRCodeUtils.genQRCodeImageBytes(str, logoPath, QRCODE_SIZE, QRCODE_SIZE);
}

public static byte[] genQRCodeImageBytes(String str, String logoPath, int width, int height) {
byte[] qrcode = null;
try {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
Hashtable<EncodeHintType, Object> hints = new Hashtable<>();
hints.put(EncodeHintType.CHARACTER_SET, "utf-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
BitMatrix bitMatrix = qrCodeWriter.encode(str, BarcodeFormat.QR_CODE, width, height, hints);

ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream();
if (StringUtils.isBlank(logoPath)) {
MatrixToImageWriter.writeToStream(bitMatrix, FORMAT_PNG, pngOutputStream);
} else {
BufferedImage image = QRCodeUtils.toBufferedImage(bitMatrix);
QRCodeUtils.addLogo(image, logoPath);
ImageIO.write(image, FORMAT_PNG, pngOutputStream);
}
qrcode = pngOutputStream.toByteArray();
} catch (WriterException e) {
System.out.println("Could not generate QR Code, WriterException :: " + e.getMessage());
e.printStackTrace();
} catch (IOException e) {
System.out.println("Could not generate QR Code, IOException :: " + e.getMessage());
e.printStackTrace();
}
return qrcode;
}

private static BufferedImage toBufferedImage(BitMatrix matrix) {
int width = matrix.getWidth();
int height = matrix.getHeight();
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
image.setRGB(x, y, (matrix.get(x, y) ? BLACK : WHITE));
//image.setRGB(x, y, (matrix.get(x, y) ? Color.YELLOW.getRGB() : Color.CYAN.getRGB()));
}
}
return image;
}

// 二维码加 logo
private static BufferedImage addLogo(BufferedImage matrixImage, String logoPath) {
int matrixWidth = matrixImage.getWidth();
int matrixHeigh = matrixImage.getHeight();

//读取二维码图片,并构建绘图对象
Graphics2D g2 = matrixImage.createGraphics();
BufferedImage logoImage;
try {
logoImage = ImageIO.read(new File(logoPath));//读取Logo图片
// 开始绘制图片
g2.drawImage(logoImage, matrixWidth / 5 * 2, matrixHeigh / 5 * 2, matrixWidth / 5, matrixHeigh / 5, null);// 绘制
BasicStroke stroke = new BasicStroke(5, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND);
g2.setStroke(stroke);// 设置笔画对象
RoundRectangle2D.Float round = new RoundRectangle2D.Float(matrixWidth / 5 * 2, matrixHeigh / 5 * 2, matrixWidth / 5, matrixHeigh / 5, 20, 20);//指定弧度的圆角矩形
g2.setColor(Color.white);
g2.draw(round);// 绘制圆弧矩形

// 设置logo 有一道灰色边框
BasicStroke stroke2 = new BasicStroke(1, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND);
g2.setStroke(stroke2);// 设置笔画对象
RoundRectangle2D.Float round2 = new RoundRectangle2D.Float(matrixWidth / 5 * 2 + 2, matrixHeigh / 5 * 2 + 2, matrixWidth / 5 - 4, matrixHeigh / 5 - 4, 20, 20);
g2.setColor(new Color(128, 128, 128));
g2.draw(round2);// 绘制圆弧矩形

} catch (IOException e) {
e.printStackTrace();
}

g2.dispose();
matrixImage.flush();
return matrixImage;
}
}