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;
}
}

Spring boot 发起http请求的简单用法

java后端发起http请求的方法经过不断演化(HttpURLConnection->HttpClient->CloseableHttpClient->RestTemplate),变得越来越简单方便。
Spingboot项目通过下面的配置,即可直接注入使用restTemplate很方便地发起http请求。

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 java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate(ClientHttpRequestFactory factory){
RestTemplate template = new RestTemplate(factory);
StringHttpMessageConverter stringHttpMessageConverter = new StringHttpMessageConverter(Charset.forName("UTF-8")); //避免中文乱码
List<HttpMessageConverter<?>> list= new ArrayList<HttpMessageConverter<?>>();
list.add(stringHttpMessageConverter);
template.setMessageConverters(list);
return template;
}

@Bean
public ClientHttpRequestFactory simpleClientHttpRequestFactory(){
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(15000);
factory.setReadTimeout(5000);
return factory;
}
}

调用restTemplate.getXXX()或postXXX()等方法即可发起对应Method的http请求。特殊的请求头和请求体可以在request参数里使用HttpEntity。如调用下面这个方法可以把data封装成xml请求:

1
2
3
4
5
6
HttpEntity<String> genXmlRequest(Object data) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
String body = XmlUtils.stringify(data, "xml");
return new HttpEntity<String>(body, headers);
}

Spring boot打jar包独立出依赖库的方法

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
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layout>ZIP</layout>
<includes>
<include><!-- 只包含自己 -->
<groupId>cn.com.mine.groupid</groupId>
<artifactId>mine-artifact</artifactId>
</include>
</includes>
</configuration>
</plugin>
<!-- 拷贝出依赖库 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>
${project.build.directory}/lib
</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>

启动时增加加载依赖包的路径

1
java -Dloader.path="lib/" -jar xxx.jar

Spring boot Websocket & SseEmitter

现代浏览器已经普遍支持WebSocket和EventSource,可以用它们实现与服务器的实时通信。
WebSocket复杂些,但是双工的;EventSource相对简单且能自动重连,但仅支持服务端推。

WebSocket 配置

Spring boot加入下面的依赖即可使用WebSocket

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

WebSocketConfig.class

注册 Websocket Handler & Interceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Bean
public TextWebSocketHandler myWebSocketHandler() {
return new MyWebSocketHandler();
}

@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myWebSocketHandler(), "/myweb/socket").addInterceptors(new WebSocketInterceptor()).setAllowedOrigins("*");//https://www.cnblogs.com/exmyth/p/11720371.html
//registry.addHandler(myWebSocketHandler(), "/myweb/sockjs").addInterceptors(new WebSocketInterceptor()).withSockJS();
}

@Bean
public TaskScheduler taskScheduler() {//避免找不到TaskScheduler Bean
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(10);
taskScheduler.initialize();
return taskScheduler;
}
}

WebSocketInterceptor.class

1
2
3
4
5
6
7
8
9
10
11
12
13
public class WebSocketInterceptor extends HttpSessionHandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
String channel = ((ServletServerHttpRequest)request).getServletRequest().getParameter("ch");
attributes.put("channel", channel);//传参
return super.beforeHandshake(request, response, wsHandler, attributes);
}

@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception ex) {
super.afterHandshake(request, response, wsHandler, ex);
}
}

MyWebSocketHandler.class

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
@Slf4j
public class MyWebSocketHandler extends TextWebSocketHandler{
@Autowired MyWebSocketService myWebSocketService;//注入需要的Service

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String channel = (String)session.getAttributes().get("channel");//获取参数
//记下session和参数用于下一步发消息...
}

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String channel = (String)session.getAttributes().get("channel");
//做会话关闭后的处理...
}

@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
log.debug("receive text message: " + message.getPayload());
//收到消息的处理...
}

public void send(WebSocketSession session, String text) {
try {
TextMessage message = new TextMessage(text);
session.sendMessage(message);//发送消息的方法
} catch (Exception e) {
e.printStackTrace();
}
}
}

SseEmitter

Controller方法返回SseEmitter对象即可为客户端提供EventSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static Set<SseEmitter> emitters = new HashSet<>();
@RequestMapping("/myweb/eventsource")
@ResponseBody
SseEmitter eventSource(String ch) {
SseEmitter emitter = new SseEmitter(0L);
emitters.put(emitter);//记下emitter用于之后发送数据
emitter.onCompletion(() -> {
emitters.remove(emitter);//做连接关闭后的处理(ch, emitter)...
});
emitter.onTimeout(() -> {
emitter.complete();
});
emitter.onError((e) -> {
emitter.completeWithError(e);
});
return emitter;
}

向所有的emitters发送数据text

1
2
3
4
5
6
7
8
SseEventBuilder builder = SseEmitter.event().data(text);
emitters.forEach(emitter -> {
try {
emitter.send(builder);
} catch (Exception e) {
errors.add(emitter);
}
});

客户端连接

前端js对象WebSocket和EventSource分别用于连接这两种服务。
具体用法略。

Nginx需要的额外配置

EventSource

1
2
3
4
5
6
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
gzip off;
chunked_transfer_encoding off;

WebSocket

1
2
3
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";

已知问题

  1. 火狐下EventSource中断之后不会自动重连。
  2. IE系列浏览器都不支持EventSource。