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。

Java poi 读写excel工具类

依赖

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.14</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.14</version>
</dependency>

WorkbookUtils.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
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import org.apache.poi.hssf.usermodel.HSSFCellStyle;
import org.apache.poi.hssf.usermodel.HSSFFont;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.hssf.util.HSSFColor;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.WorkbookUtil;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.tomcat.util.http.fileupload.IOUtils;

public class WorkbookUtils extends WorkbookUtil {
// 读取excel文件
public static Workbook readExcel(String filePath) {
Workbook wb = null;
if (filePath == null) {
return null;
}
String extString = filePath.substring(filePath.lastIndexOf("."));
InputStream is = null;
try {
is = new FileInputStream(filePath);
if (".xls".equals(extString)) {
return wb = new HSSFWorkbook(is);
} else if (".xlsx".equals(extString)) {
return wb = new XSSFWorkbook(is);
} else {
return wb = null;
}

} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(is);
}
return wb;
}

//读取单元格
public static Object getCellFormatValue(Cell cell, FormulaEvaluator formulaEvaluator) {
if (cell == null) {
return null;
}
Object cellValue = null;
// 判断cell类型
int cellType = cell.getCellType();
if (cellType == Cell.CELL_TYPE_FORMULA) {
cellType = formulaEvaluator.evaluateFormulaCell(cell);
}
switch (cellType) {
case Cell.CELL_TYPE_STRING:
cellValue = cell.getRichStringCellValue().getString();
break;
case Cell.CELL_TYPE_NUMERIC:
if (DateUtil.isCellDateFormatted(cell)) {// 判断cell是否为日期格式
cellValue = cell.getDateCellValue();
break;
}
DataFormatter dataFormatter = new DataFormatter();
cellValue = dataFormatter.formatCellValue(cell, formulaEvaluator);
break;
case Cell.CELL_TYPE_BOOLEAN:
cellValue = cell.getBooleanCellValue();
break;
default:
cellValue = "";
}
return cellValue;
}

// 设置报表头样式
public static CellStyle createHeadSytle(Workbook workbook) {
CellStyle style1 = workbook.createCellStyle();// cell样式
// 设置单元格背景色,设置单元格背景色以下两句必须同时设置
style1.setFillPattern(HSSFCellStyle.SOLID_FOREGROUND);// 设置填充样式
style1.setFillForegroundColor(HSSFColor.GREY_25_PERCENT.index);// 设置填充色
// 设置单元格上、下、左、右的边框线
style1.setBorderBottom(HSSFCellStyle.BORDER_THIN);
style1.setBorderLeft(HSSFCellStyle.BORDER_THIN);
style1.setBorderRight(HSSFCellStyle.BORDER_THIN);
style1.setBorderTop(HSSFCellStyle.BORDER_THIN);
Font font1 = workbook.createFont();// 创建一个字体对象
font1.setBoldweight((short) 10);// 设置字体的宽度
font1.setFontHeightInPoints((short) 10);// 设置字体的高度
font1.setBoldweight(HSSFFont.BOLDWEIGHT_BOLD);// 粗体显示
style1.setFont(font1);// 设置style1的字体
// style1.setWrapText(true);// 设置自动换行
style1.setAlignment(HSSFCellStyle.ALIGN_CENTER);// 设置单元格字体显示居中(左右方向)
style1.setVerticalAlignment(HSSFCellStyle.VERTICAL_CENTER);// 设置单元格字体显示居中(上下方向)
return style1;
}

// 设置报表体样式
public static CellStyle createCellStyle(Workbook wb) {
// 设置style1的样式,此样式运用在第二行
CellStyle style1 = wb.createCellStyle();// cell样式
// 设置单元格上、下、左、右的边框线
style1.setBorderBottom(HSSFCellStyle.BORDER_THIN);
style1.setBorderLeft(HSSFCellStyle.BORDER_THIN);
style1.setBorderRight(HSSFCellStyle.BORDER_THIN);
style1.setBorderTop(HSSFCellStyle.BORDER_THIN);
// style1.setWrapText(true);// 设置自动换行
style1.setAlignment(HSSFCellStyle.ALIGN_LEFT);// 设置单元格字体显示居中(左右方向)
style1.setVerticalAlignment(HSSFCellStyle.VERTICAL_CENTER);// 设置单元格字体显示居中(上下方向)
return style1;
}
}

Java commons-exec 执行外部命令

Java创建子进程(Process)执行外部命令底层的方法是new ProcessBuilder().start()或Runtime.getRuntime().exec()。

Apache commons-exec对底层进行封装,提供了更加详细的设置和监控方法。

pom.xml

1
2
3
4
5
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.3</version>
</dependency>

CmdHelper.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
import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.CompletableFuture;

import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.ExecuteWatchdog;
import org.apache.commons.exec.PumpStreamHandler;
import org.apache.commons.exec.ShutdownHookProcessDestroyer;

public class CmdHelper {
/**
* 执行外部命令,等待返回结果
*
* @param commandLine: 命令行
* @param out: 输出流,为空默认标准输出
* @param timeout: 超时,不大于0则不限超时
* @return CmdHandler based on DefaultExecuteResultHandler
*/
public static CmdHandler run(CommandLine commandLine, OutputStream out, long timeout) {
PumpStreamHandler pumpStreamHandler = null;
if (null == out) {
pumpStreamHandler = new PumpStreamHandler();
} else {
pumpStreamHandler = new PumpStreamHandler(out);
}

DefaultExecutor executor = new DefaultExecutor();
CmdHandler cmdHandler = new CmdHandler(executor);
executor.setStreamHandler(pumpStreamHandler);
ShutdownHookProcessDestroyer processDestroyer = new ShutdownHookProcessDestroyer();
executor.setProcessDestroyer(processDestroyer);// 随主进程退出

if (timeout <= 0) {
timeout = ExecuteWatchdog.INFINITE_TIMEOUT;
}
ExecuteWatchdog watchdog = new ExecuteWatchdog(timeout);
executor.setWatchdog(watchdog);// 控制超时

try {
executor.execute(commandLine, cmdHandler);
cmdHandler.waitFor();// 等待返回结果
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
return cmdHandler;
}

/**
* 异步执行外部命令
*
* @param commandLine: 命令行
* @param out: 输出流,为空默认标准输出
* @param timeout: 超时,不大于0则不限超时
* @return CompletableFuture<CmdHandler>
*/
public static CompletableFuture<CmdHandler> exec(CommandLine commandLine, OutputStream out, long timeout) {
CompletableFuture<CmdHandler> cf = new CompletableFuture<>();
PumpStreamHandler pumpStreamHandler = null;
if (null == out) {
pumpStreamHandler = new PumpStreamHandler();
} else {
pumpStreamHandler = new PumpStreamHandler(out);
}

DefaultExecutor executor = new DefaultExecutor();
CmdHandler cmdHandler = new CmdHandler(executor);
cmdHandler.setCallback(() -> {
cf.complete(cmdHandler);// 执行完成后回调
});
executor.setStreamHandler(pumpStreamHandler);
ShutdownHookProcessDestroyer processDestroyer = new ShutdownHookProcessDestroyer();
executor.setProcessDestroyer(processDestroyer);// 随主进程退出

if (timeout <= 0) {
timeout = ExecuteWatchdog.INFINITE_TIMEOUT;
}
ExecuteWatchdog watchdog = new ExecuteWatchdog(timeout);
executor.setWatchdog(watchdog);// 控制超时

try {
executor.execute(commandLine, cmdHandler);
} catch (IOException e) {
e.printStackTrace();
}
return cf;
}

public static void main(String[] args) throws InterruptedException {
CommandLine command = CommandLine.parse("ping 127.0.0.1 -t");

// 测试同步执行
CmdHandler result = CmdHelper.run(command, null, 3000);
System.out.println(result.resultString());

// 测试异步执行
CmdHelper.exec(command, null, 0).thenAccept(cmdHandler -> {
System.out.println(cmdHandler.resultString());
});
}
}

CmdHandler.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 org.apache.commons.exec.DefaultExecuteResultHandler;
import org.apache.commons.exec.ExecuteException;
import org.apache.commons.exec.ExecuteWatchdog;
import org.apache.commons.exec.Executor;

public class CmdHandler extends DefaultExecuteResultHandler {
Executor executor;
Runnable callback;

public CmdHandler(Executor executor) {
this.executor = executor;
}

public void setCallback(Runnable callback) {
this.callback = callback;
}

public Executor getExecutor() {
return this.executor;
}

public ExecuteWatchdog getWatchdog() {
if (this.executor == null) return null;
return this.executor.getWatchdog();
}

public String resultString() {
String retMsg = "complete";
if (this.getException() != null) {
ExecuteWatchdog watchdog = this.getWatchdog();
if (watchdog != null && watchdog.killedProcess()) {
retMsg = "timeout";
} else {
retMsg = this.getException().getMessage();
}
}
return this.getExitValue() + ":" + retMsg;
}

@Override
public void onProcessComplete(int exitValue) {
super.onProcessComplete(exitValue);
if (callback != null) {
callback.run();
}
}

@Override
public void onProcessFailed(ExecuteException e) {
super.onProcessFailed(e);
if (callback != null) {
callback.run();
}
}
}

参考

  1. https://www.cnblogs.com/kingcucumber/p/3180146.html
  2. https://www.jianshu.com/p/73aaec23009d

java微信开发常用方法

WeixinService.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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
public class WeixinSercice {
final static String URL_SNS_TOKEN = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";
final static String URL_ACCESS_TOKEN = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
final static String URL_JSAPI_TICKET = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=%s&type=jsapi";

final static String URL_ORDER_QUERY = "https://api.mch.weixin.qq.com/pay/orderquery";
final static String URL_UNIFIED_ORDER = "https://api.mch.weixin.qq.com/pay/unifiedorder";

public static final String TRADE_TYPE_H5 = "MWEB";
public static final String TRADE_TYPE_JS = "JSAPI";

final static String KEY_SIGN = "sign";

@Value("${weixin.appid}")
private String appId;
@Value("${weixin.secret}")
private String appSecret;
@Value("${weixin.mch_id}")
private String mchId;
@Value("${weixin.mch_key}")
private String mchKey;
@Value("${weixin.notify_url}")
private String notifyUrl;

@Autowired
RestTemplate restTemplate;
@Autowired
OrderService orderService;
@Resource
private CacheManager cacheManager;
private static final String CACHE_NAME_WEIXIN = "myapp:weixin";
private static final String CACHE_KEY_ACCESS_TOKEN = "actoken";
private static final String CACHE_KEY_JSAPI_TICKET = "jsticket";
private Cache getCache() {
return cacheManager.getCache(CACHE_NAME_WEIXIN);
}

// 微信网页认证:通过code获取token
public WxWebToken fetchWebTokenByCode(String code) {
String url = String.format(URL_SNS_TOKEN, appId, appSecret, code);
String content = restTemplate.getForObject(url, String.class);
WxWebToken token = JSON.parse(content, WxWebToken.class);
return token;
}

// 微信JSSDK:获取指定url的config
public WxJsdkConfig genJsdkConfig(String url) {
WxJsdkConfig config = new WxJsdkConfig();
config.setAppId(appId);
config.setNonceStr(StringUtils.uuid());
config.setTimestamp(DateTimeUtils.secondsOf(LocalDateTime.now()));

String ticket = getJsapiTicket(url);
if (StringUtils.isNotBlank(ticket)) {
String string = "jsapi_ticket=" + ticket +
"&noncestr=" + config.getNonceStr() +
"&timestamp=" + config.getTimestamp() +
"&url=" + url;
String signature = StringUtils.SHA1(string);

if (StringUtils.isNotBlank(signature)) {
config.setSignature(signature);
}
} else {
config.setErrcode(1);
config.setErrmsg("invalid ticket");
}
log.debug("genJsdkConfig for {} return {}", url, config.getSignature());
return config;
}

//get cached jsapi_ticket
private String getJsapiTicket(String url) {
String cacheKey = CACHE_KEY_JSAPI_TICKET+StringUtils.MD5(url);
WxJsapiTicket ticket = getCache().get(cacheKey, WxJsapiTicket.class);
if (ticket == null || ticket.getExpired()) {
ticket = fetchJsapiTicket();
if (ticket != null && !ticket.getExpired()) {
getCache().put(cacheKey, ticket);
}else {
return null;
}
} else {
log.debug("return cached {} for key {}", CACHE_NAME_WEIXIN, cacheKey);
}
return ticket.getTicket();
}

private WxJsapiTicket fetchJsapiTicket() {
String accessToken = getAccessToken();
if (StringUtils.isBlank(accessToken)) {
return null;
}

String url = String.format(URL_JSAPI_TICKET, accessToken);
String content = restTemplate.getForObject(url, String.class);
WxJsapiTicket ticket = JSON.parse(content, WxJsapiTicket.class);
if (ticket != null) {
Long expires = ticket.getExpires_in();
if (expires != null) {//把过期秒数转化为世纪秒
expires += DateTimeUtils.secondsOf(LocalDateTime.now());
}else{
expires = 0L;
}
ticket.setExpires_in(expires);
log.debug("fetchJsapiTicket return {}-{}", ticket.getErrcode(), ticket.getErrmsg());
}
return ticket;
}

// get cached access_token
private String getAccessToken() {
WxAccessToken token = getCache().get(CACHE_KEY_ACCESS_TOKEN, WxAccessToken.class);
if (token == null || token.getExpired()) {
token = fetchAccessToken();
if (token != null && !token.getExpired()) {
getCache().put(CACHE_KEY_ACCESS_TOKEN, token);
}
} else {
log.debug("return cached {} for key {}", CACHE_NAME_WEIXIN, CACHE_KEY_ACCESS_TOKEN);
}
return token.getAccess_token();
}

private WxAccessToken fetchAccessToken() {
String url = String.format(URL_ACCESS_TOKEN, appId, appSecret);
String content = restTemplate.getForObject(url, String.class);
WxAccessToken token = JSON.parse(content, WxAccessToken.class);
if (token != null) {
Long expires = token.getExpires_in();
if (expires != null) {//把过期秒数转化为世纪秒
expires += DateTimeUtils.secondsOf(LocalDateTime.now());
}else{
expires = 0L;
}
token.setExpires_in(expires);
if (StringUtils.isNotBlank(token.getErrmsg())){
log.debug("fetchAccessToken return {}-{}", token.getErrcode(), token.getErrmsg());
}else{
log.debug("fetchAccessToken return {}", token.getAccess_token());
}
} else {
log.debug("fetchAccessToken return null");
}
return token;
}


//处理订单
public Order processOrder(Order order) {
if (StringUtils.isNotBlank(order.getId())) {
Order dbOrder = orderService.findById(order.getId());
if (dbOrder != null && dbOrder.getStatus()>=Order.STATUS_PAYED) {
return dbOrder;//已支付
}
}
order = orderService.upsert(order);
Map<String, String> map = placeOrder(order);
if (map != null) {
String returnCode = map.get("return_code");
order.setReturnCode(returnCode);
if ("SUCCESS".equals(returnCode)) {
order.setStatus(Order.STATUS_ORDER);
}
order.setReturnMsg(map.get("return_msg"));
order.setMwebUrl(map.get("mweb_url"));
order.setPrepayId(map.get("prepay_id"));
orderService.save(order);
orderService.sendNotify(order);
if (TRADE_TYPE_JS.equals(order.getTradeType())) {
Map<String, String> signs = new TreeMap<>();
signs.put("appId", appId);
signs.put("nonceStr", StringUtils.uuid());
signs.put("package", "prepay_id="+map.get("prepay_id"));
signs.put("signType", "MD5");
signs.put("timeStamp", String.valueOf(DateTimeUtils.secondsOf(LocalDateTime.now())));
signs.put("paySign", genSign(signs));
order.setSigns(signs);
}
}
return order;
}

//下单
private Map<String, String> placeOrder(Order order) {
Map<String, String> map = null;
String tradeType = order.getTradeType();
if (TRADE_TYPE_H5.equals(tradeType)) {
map = prepareH5Order(order.getTradeNo(), order.getProductId(), order.getProductName(), order.getTotalFee(), order.getIpaddr());
}else{
map = prepareJsOrder(order.getTradeNo(), order.getProductId(), order.getProductName(), order.getTotalFee(), order.getIpaddr(), order.getOpenid());
}
HttpEntity<String> request = genXmlRequest(map);
String res = restTemplate.postForObject(URL_UNIFIED_ORDER, request, String.class);
log.debug(res);
return XmlUtils.parse(res);
}

// H5支付下单数据
private Map<String, String> prepareH5Order(String tradeNo, String productId, String productName, Long amount, String ip) {
Map<String, String> order = newOrderMap();
order.put("trade_type", TRADE_TYPE_H5);//H5支付的交易类型为MWEB
order.put("notify_url", notifyUrl);//回调地址, 不能携带参数。
order.put("scene_info", "{\"h5_info\": {\"type\":\"WAP\",\"wap_url\": \"\",\"wap_name\": \"\"}}");//用于上报支付的场景信息
order.put("spbill_create_ip", ip);//用户端IP,支持ipv4、ipv6格式
order.put("out_trade_no", tradeNo);//自定义交易单号
order.put("product_id", productId);//自定义商品
order.put("body", productName);//网页的主页title名-商品概述
order.put("fee_type", "CNY");//境内只支持CNY,默认可不传
order.put("total_fee", String.valueOf(amount));//订单总金额,单位为分
//签名
order.put(KEY_SIGN, genSign(order));
return order;
}

// JSAPI支付下单数据
private Map<String, String> prepareJsOrder(String tradeNo, String productId, String productName, Long amount, String ip, String openid) {
Map<String, String> order = newOrderMap();
order.put("trade_type", TRADE_TYPE_JS);//交易类型为JSAPI
order.put("notify_url", notifyUrl);//回调地址, 不能携带参数。
//order.put("scene_info", "{\"h5_info\": {\"type\":\"WAP\",\"wap_url\": \"\",\"wap_name\": \"\"}}");//用于上报支付的场景信息
order.put("openid", openid);
order.put("spbill_create_ip", ip);//用户端IP,支持ipv4、ipv6格式
order.put("out_trade_no", tradeNo);//自定义交易单号
order.put("product_id", productId);//自定义商品
order.put("body", productName);//网页的主页title名-商品概述
order.put("fee_type", "CNY");//境内只支持CNY,默认可不传
order.put("total_fee", String.valueOf(amount));//订单总金额,单位为分
//签名
order.put(KEY_SIGN, genSign(order));
return order;
}

// 下单数据准备:公用部分
private Map<String, String> newOrderMap() {
Map<String, String> order = new TreeMap<>();
order.put("appid", appId);
order.put("mch_id", mchId);
order.put("nonce_str", StringUtils.uuid());
return order;
}

// 构造xml request
private 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);
}

// 生成签名
private String genSign(Map<String, String> paramMap) {
StringBuilder sb = new StringBuilder();
paramMap.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach((a) -> {
if (StringUtils.isBlank(a.getKey()) || KEY_SIGN.equals(a.getKey())) {
return;
}
if (StringUtils.isBlank(a.getValue())) {
return;
}
sb.append(a.getKey()); sb.append("="); sb.append(a.getValue()); sb.append("&");
});
sb.append("key="); sb.append(mchKey);
String signStr = sb.toString();
log.debug(signStr);
return StringUtils.MD5(signStr).toUpperCase();
}

//微信支付结果回调处理
@Synchronized // TODO: 避免重入仅这样不够,还需要锁定订单记录
public String processCallback(String data) {
Map<String, String> map = XmlUtils.parse(data);
String sign = genSign(map);
if (!sign.equals(map.get(KEY_SIGN))) {
return returnCodeMsg("FAIL", "SIGNERROR");
};
String tradeNo = map.get("out_trade_no");
Order order = orderService.findByTradeNo(tradeNo);
if (order == null) {
return returnCodeMsg("FAIL", "NOTFOUND");
}
if (order.getStatus() >= Order.STATUS_PAYED) {
return returnCodeMsg("SUCCESS", "OK!");
}
String tradeType = map.get("trade_type");
long totalFee = NumberUtils.parse(map.get("total_fee"), Long.class, 0L);
if (totalFee != order.getTotalFee() || !StringUtils.equals(tradeType, order.getTradeType())) {
return returnCodeMsg("FAIL", "TRADEINFOERROR");
}

order.setTransactionId(map.get("transaction_id"));
order.setReturnCode(map.get("return_code"));
order.setResultCode(map.get("result_code"));
order.setBankType(map.get("bank_type"));
order.setTimeEnd(map.get("time_end"));
order.setStatus(Order.STATUS_PAYED);
orderService.save(order);

return returnCodeMsg("SUCCESS", "OK");
}

private String returnCodeMsg(String code, String msg) {
return String.format("<xml><return_code><![CDATA[%s]]></return_code><return_msg><![CDATA[%s]]></return_msg></xml>", code, msg);
}

}

## WxResponse.java
```java
@Data
public class WxResponse implements Serializable {
private Integer errcode;
private String errmsg;
}

WxResexpire.java

1
2
3
4
5
6
7
@Data
public class WxResexpire extends WxResponse implements Serializable {
Long expires_in;
public Boolean getExpired() {
return expires_in == null || expires_in <= DateTimeUtils.secondsOf(LocalDateTime.now());
}
}

WxAccessToken.java

1
2
3
4
@Data
public class WxAccessToken extends WxResexpire implements Serializable {
String access_token;
}

WxWebToken.java

1
2
3
4
5
6
@Data
public class WxWebToken extends WxAccessToken implements Serializable {
String refresh_token;
String openid;
String scope;
}

WxJsapiTicket.java

1
2
3
4
@Data
public class WxJsapiTicket extends WxResexpire implements Serializable {
String ticket;
}

WxJsdkConfig.java

1
2
3
4
5
6
7
8
@Data
@JsonInclude(value = Include.NON_NULL)
public class WxJsdkConfig extends WxResponse implements Serializable {
String appId;
Long timestamp;
String nonceStr;
String signature;
}

Spring boot thymeleaf使用外置template和static路径

Spring boot开发的web项目打jar包部署时,如果希望template模板及static文件(js/css/img等)能单独更新,可以用下面的方法把它们从jar包分离出来。

工程目录结构调整

把static和template移到resources之外,比如和java目录平级。

1
2
3
4
5
6
7
8
├─src
│ ├─main
│ │ ├─java
│ │ ├─resources
│ │ │ └─application.properties
│ │ ├─static
│ │ └─templates
│ └─test

在 application.properties 分别指定static和template的位置

1
2
3
4
spring.mvc.static-path-pattern=/static/**
spring.resources.static-locations=file:/pathto/yourproject/src/main/static/
spring.thymeleaf.prefix=file:/pathto/yourproject/src/main/templates/
#spring.thymeleaf.cache=false

html模板里引用static文件的方式

1
2
3
<link rel="stylesheet" th:href="@{/static/subpath/afile.css}" />
<script th:src="@{/static/subpath/afile.js}"></script>
<img th:src="@{/static/subpath/afile.png}"/>

注:link favicon不知为何不能加static:(

把static和template合并,方便一起编辑

实践中发现,html模板和对应的静态文件(js,css,images等)分开在两处不方便编辑。尝试把他们合并到同一个文件夹下。首先只要修改下面两个配置都指向一个地方(比如webroot):

1
2
spring.resources.static-locations=file:/pathto/yourproject/src/main/webroot/
spring.thymeleaf.prefix=file:/pathto/yourproject/src/main/webroot/

只是这样会导致html模板也可以被用户直接访问,不太安全。需要调整一下security配置:

  • 配置方法configure(WebSecurity webSecurity) :
    1
    2
    3
    4
    5
    6
    // 忽略静态资源改为只忽略特定类型的静态资源,目的是不忽略*.html模板
    //webSecurity.ignoring().antMatchers("/static/**");
    webSecurity.ignoring().antMatchers("/static/**/*.js");
    webSecurity.ignoring().antMatchers("/static/**/*.css");
    webSecurity.ignoring().antMatchers("/static/**/*.jpg");
    webSecurity.ignoring().antMatchers("/static/**/*.png");
  • 配置方法configure(HttpSecurity httpSecurity):
    1
    2
    //禁止直接访问html模板
    httpSecurity.authorizeRequests().antMatchers("/static/**/*.html").denyAll()

Spring boot 处理 error

参考: https://www.cnblogs.com/hyl8218/p/10754894.html
Spring boot 处理 error 的基本流程:
Controller -> 发生错误 -> BasicErrorController -> 根据 @RequestMapping(produces) 判断调用 errorHtml 或者 error 方法,然后:
errorHtml -> getErrorAttributes -> ErrorViewResolver -> 错误显示页面
error -> getErrorAttributes -> @ResponseBody (直接返回JSON)

附:为静态文件加md5(避免浏览器缓存旧文件)

1
2
spring.resources.chain.strategy.content.enabled=true
spring.resources.chain.strategy.content.paths=/**

附一:content-path 相关处理

配置项

1
server.servlet.context-path=/myweb

后端获取basePath

1
2
3
4
5
6
7
public static String getWebBasePath() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) return null;//防止意外
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getResponse();
if (request == null) return null;//防止意外
return String.format("%s://%s:%s%s/", request.getScheme(), request.getServerName(), request.getServerPort(), request.getContextPath());
}

在js里获取contentPath

1
2
3
<script th:inline="javascript">
var contextPath = /*[[${#request.contextPath}]]*/'';
</script>

注:在html模板里使用th:href或th:src时带”@”符号会自动处理contentPath

附二:为thymeleaf模板设置全局静态变量

以配置第三方库路径为例

配置项

1
basepath.lib=https://cdnjs.cloudflare.com/ajax/

配置 ThymeleafViewResolver

1
2
3
4
5
6
7
8
9
10
11
@Resource
private Environment env; //环境变量

@Resource
private void configureThymeleafStaticVars(ThymeleafViewResolver viewResolver) {
if(viewResolver != null) {
Map<String, Object> vars = new HashMap<>();
vars.put("libBasePath", env.getProperty("basepath.lib"));//静态库配置
viewResolver.setStaticVariables(vars);
}
}

使用libBasePath引入第三方库(比如vue.js)

1
<script th:src="${libBasePath}+'libs/vue/2.6.10/vue.js'"></script>

附三:html模板共用head

common.html 里定义公用head

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="zh" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head">
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"/>
<link rel="icon" type="image/x-icon" th:href="@{/favicon.ico}">
<link rel="stylesheet" th:href="${libBasePath}+'libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css'"/>
<script th:src="${libBasePath}+'libs/vue/2.6.10/vue.js'"></script>
<link th:href="@{/static/base.css}" rel="stylesheet"/>
<script th:src="@{/static/base.js}"></script>
<script th:inline="javascript">var contentPath = /*[[${#request.contextPath}]]*/'';</script>
</head>
<body>
</body>
<html>

index.html 里引用common::head

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="zh" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<title>我的页面</title>
<th:block th:include="common::head"></th:block>
<link rel="stylesheet" th:href="@{/static/index.css}" />
</head>
<body>
<script th:inline="javascript">
var varForJs = /*[[${varForJs}]]*/{};
</script>
<script th:src="@{/static/index.js}"></script>
<body>
</html>

附四:使用thymeleaf简单制作vue组件

用tab-bar做例子

tabar.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
32
33
<!DOCTYPE html>
<html lang="zh" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<body>
<th:block th:fragment="tabar">
<script type="text/x-template" id="tabar-tpl">
<div class="nav nav-tabs">
<a class="nav-item nav-link" href="#"
v-for="(tabName,tabKey) in tabs" v-show="!!tabName"
:class="{active: active == tabKey}"
@click="onClick(tabKey, tabName)">{{tabName}}</a>
<slot></slot>
</div>
</script>
<script th:inline="javascript">
Vue.component('tabar', {
template: '#tabar-tpl',
props:{
tabs: [Object, Array],
active: [String, Number],
},
data() {
return {}
},
methods: {
onClick(tabKey, tabName){
this.$emit('click-tab', {key: tabKey, name: tabName})
},
}
});
</script>
</th:block>
</body>
</html>

引入和使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<head>
<th:block th:include="tabar::tabar"></th:block>
</head>
<body>
<div id="root">
<tabar :tabs="tabs" :active="activeTab" @click-tab="activeTab=$event.key"></tabar>
</div>
</body>
<script>
new Vue({
el: '#root',
data: {
tabs: {a:'Tab A', b:'Tab B'},
activeTab: 'a',
},
methods: {
},
});
</script>