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>

Java转码服务

记录用java实现转码服务的核心代码。功能包括:把视频转为mp4、把文档转为pdf、生成视频和文档的缩略图等。

ConvertService.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
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import lombok.Synchronized;

@Service
public class ConvertService {
@Autowired
ConvertConfig convertConfig;
@Autowired
ConvertTaskDao convertTaskDao;
@Autowired
ConvertHelper convertHelper;
@Autowired
RestTemplate restTemplate;

private static Thread convertThread = null;
private static Thread notifyThread = null;
private static ConcurrentLinkedQueue<ConvertTask> queueTasks = new ConcurrentLinkedQueue<>();
private static ConcurrentHashMap<String, ConvertTask> convertingTasks = new ConcurrentHashMap<>();
private static ConcurrentLinkedQueue<ConvertTask> notifyTasks = new ConcurrentLinkedQueue<>();

@Synchronized
public ConvertTask addTask(ConvertTask task) {
ConvertTask save = convertTaskDao.save(task);
queueTasks.add(save);
startConvert();
return task;
}

@Synchronized
private int doConvert() {
if (convertingTasks.size() >= 4) {
return 1;
}
ConvertTask task = queueTasks.poll();
if (task == null) {
return 10;
}
try {
if (convertingTasks.containsKey(task.getSrc())) {//重复
task.setStatus(ConvertTask.STATUS_IGNORE);
convertTaskDao.save(task);
return 0;
}
convertingTasks.put(task.getSrc(), task);
task.setStatus(ConvertTask.STATUS_START);
task.setStartAt(Instant.now());
convertTaskDao.save(task);

CompletableFuture<ConvertTask> process = convertHelper.process(task);
process.thenAccept((retTask) -> {
retTask.setStatus(ConvertTask.STATUS_FINISH);
retTask.setEndAt(Instant.now());
convertTaskDao.save(retTask);
convertingTasks.remove(task.getSrc());
notifyTasks.add(retTask);
startNotify();
System.out.println(retTask);
});
} catch(Exception e) {
e.printStackTrace();
}
return 0;
}

@Synchronized
private int doNotify() {
ConvertTask task = notifyTasks.poll();
if (task == null) {
return 10;
}
int sleep = 0;
try {
ResponseEntity<String> response = restTemplate.postForEntity(convertConfig.getNotify(), task, String.class);
String success = response.getBody();
System.out.println(success);
if (!StringUtils.isNullOrEmpty(success) && success.contains("success")) {
task.setStatus(ConvertTask.STATUS_NOTIFIED);
task.setNotify(LocalDateTime.now()+","+success);
} else {
task.setNotify(LocalDateTime.now()+",empty");
notifyTasks.add(task);
sleep = 1;
}
} catch(Exception e) {
e.printStackTrace();
task.setNotify(LocalDateTime.now()+","+e.getMessage());
notifyTasks.add(task);
sleep = 2;
}
convertTaskDao.save(task);
return sleep;
}

public void startConvert() {
if (convertThread != null) {
convertThread.interrupt();
return;
}
convertThread = new Thread(() -> {
while (convertThread == Thread.currentThread()) {
System.out.println("Convert "+convertThread + " running at " + LocalDateTime.now());
int sleep = doConvert();
try {
Thread.sleep(sleep*1000);
} catch (InterruptedException e) {
System.out.println("Convert "+Thread.currentThread() +" "+ e.getMessage());
}
}
System.out.println("Convert "+Thread.currentThread()+" stoped at " + LocalDateTime.now());
});
convertThread.start();
System.out.println("Convert "+convertThread+" started at " + LocalDateTime.now());

}

public void startNotify() {
if (notifyThread != null) {
notifyThread.interrupt();
return;
}
notifyThread = new Thread(() -> {
while (notifyThread == Thread.currentThread()) {
System.out.println("Notify "+notifyThread + " running at " + LocalDateTime.now());
int sleep = doNotify();
try {
Thread.sleep(sleep*1000);
} catch (InterruptedException e) {
System.out.println("Notify "+Thread.currentThread() +" "+ e.getMessage());
}
}
System.out.println("Notify "+Thread.currentThread()+" stoped at " + LocalDateTime.now());
});
notifyThread.start();
System.out.println("Notify "+notifyThread+" started at " + LocalDateTime.now());
}

@PostConstruct
private void init() {
List<ConvertTask> tasks = convertTaskDao.findByStatus(ConvertTask.STATUS_START);
tasks.forEach(task -> {
queueTasks.add(task);
});
tasks = convertTaskDao.findByStatus(ConvertTask.STATUS_INITIAL);
tasks.forEach(task -> {
queueTasks.add(task);
});
tasks = convertTaskDao.findByStatus(ConvertTask.STATUS_FINISH);
tasks.forEach(task -> {
notifyTasks.add(task);
});
startConvert();
startNotify();
System.out.println(convertConfig);
}

@PreDestroy
public void stop() {
if (convertThread != null) {
convertThread.interrupt();
convertThread = null;
}
if (notifyThread != null) {
notifyThread.interrupt();
notifyThread = null;
}
}
}

ConvertTaskDao.java

1
2
3
4
5
6
7
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.query.Param;

public interface ConvertTaskDao extends JpaRepository<ConvertTask, Integer>{
List<ConvertTask> findByStatus(@Param("status") Integer status);
}

ConvertTask实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ConvertTask implements Serializable{
private static final long serialVersionUID = 1418649194096365375L;
//状态常量
public static final Integer STATUS_IGNORE = -1;
public static final Integer STATUS_INITIAL = 0;
public static final Integer STATUS_START = 1;
public static final Integer STATUS_FINISH = 2;
public static final Integer STATUS_NOTIFIED = 3;

@Id
Integer id;
String name;
Instant createdAt;
Instant startAt;
Instant endAt;
Integer status = STATUS_INITIAL;
String src;//源文件
String out;//输出文件
String img;//缩略图
String ret;//转换结果
String notify;//通知结果
}

ConvertHelper.java

基于CmdHelper.java实现,见:https://www.zybuluo.com/TedZhou/note/1716823

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
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.CompletableFuture;

import javax.imageio.ImageIO;

import org.apache.commons.exec.CommandLine;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class ConvertHelper {
static final String EXT_PDF = ".pdf";
static final String EXT_MP4 = ".mp4";
static final String EXT_AVI = ".avi";
static final String EXT_DOC_STR = ".doc,.docx,.ppt,.pptx,.xls,.xlsx,";// 文档后缀
static final String EXT_VIDEO_FFMPEG_STR = ".avi,.mpg,.wmv,.3gp,.mov,.asf,.asx,.flv,.f4v,.vob,.mkv,.ts,";// ffmpeg能解码的文件格式
static final String EXT_VIDEO_MENCODER_STR = ".wmv9,.rm,.rmvb,.mpeg,";// 需要先mencoder解码的文件格式。.mpeg无法直接截取图片,所以需要中转

@Autowired
ConvertConfig convertConfig;

public static String getExt(String filename) {
if (StringUtils.isNullOrEmpty(filename)) return "";
int pos = filename.lastIndexOf(".");
if (pos < 0) return "";
return filename.substring(pos).toLowerCase();
}

public static boolean isDoc(String ext) {
return EXT_DOC_STR.contains(ext + ',');
}

public static boolean isVideo(String ext) {
return EXT_VIDEO_FFMPEG_STR.contains(ext + ',') || needMEncoder(ext);
}

public static boolean needMEncoder(String ext) {
return EXT_VIDEO_MENCODER_STR.contains(ext + ',');
}

public CompletableFuture<ConvertTask> process(ConvertTask task) {
CompletableFuture<ConvertTask> cf = new CompletableFuture<>();
new Thread(() -> {
String srcFile = task.getSrc();
String dotExt = getExt(srcFile);
String outFile = null;
CmdHandler cmdHandler = null;
FileOutputStream logStream = null;
try {
File logFile = new File(convertConfig.getLog() + srcFile + ".log");
logStream = new FileOutputStream(logFile);
if (isDoc(dotExt)) {
outFile = srcFile + EXT_PDF;
cmdHandler = doc2pdf(convertConfig.getSrc() + srcFile, convertConfig.getOut() + outFile, logStream);
if (cmdHandler.getException() == null) {
if (pdf2png(convertConfig.getOut() + outFile, convertConfig.getOut() + task.getSrc() + ".png")) {
task.setImg(task.getSrc() + ".png");
}
}
} else if (isVideo(dotExt)) {
if (needMEncoder(dotExt)) {
outFile = srcFile + EXT_AVI;
cmdHandler = video2avi(convertConfig.getSrc() + srcFile, convertConfig.getOut() + outFile, logStream);
task.setRet(cmdHandler.resultString());
if (cmdHandler.getException() != null) {
cf.complete(task);
return;
}
srcFile = outFile;
}
cmdHandler = video2jpg(convertConfig.getSrc() + srcFile, convertConfig.getOut() + task.getSrc() + ".jpg", logStream);
if (cmdHandler.getException() == null) {
task.setImg(task.getSrc() + ".jpg");
}
outFile = task.getSrc() + EXT_MP4;
cmdHandler = video2mp4(convertConfig.getSrc() + srcFile, convertConfig.getOut() + outFile, logStream);
} else {
task.setRet("-1:unsupport");
cf.complete(task);
return;
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(logStream);
}

task.setOut(outFile);
task.setRet(cmdHandler.resultString());
if (cmdHandler.getException() != null) {
task.setOut(null);
}
cf.complete(task);
}).start();
return cf;
}

CmdHandler doc2pdf(String srcFile, String outFile, OutputStream cmdOut) {
CommandLine cmd = new CommandLine(convertConfig.getUnoconv());
cmd.addArgument("-f");
cmd.addArgument("pdf");
cmd.addArgument("-o");
cmd.addArgument(outFile);
cmd.addArgument(srcFile);
return CmdHelper.run(cmd, cmdOut, 0);
}

boolean pdf2png(String srcFile, String outFile) {
try {
File file = new File(srcFile);
PDDocument doc;
doc = PDDocument.load(file);
PDFRenderer renderer = new PDFRenderer(doc);
BufferedImage image = renderer.renderImageWithDPI(1, 144); // Windows native DPI
BufferedImage tag = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB);
tag.getGraphics().drawImage(image, 0, 0, 64, 64, null);
ImageIO.write(tag, "PNG", new File(outFile));
return true;
} catch (IOException e) {
e.printStackTrace();
return false;
}
}

CmdHandler video2mp4(String srcFile, String outFile, OutputStream cmdOut) {
CommandLine cmd = new CommandLine(convertConfig.getFfmpeg());
cmd.addArgument("-i");
cmd.addArgument(srcFile);
cmd.addArgument("-ar");
cmd.addArgument("22050");
cmd.addArgument("-vcodec");
cmd.addArgument("libx264");
cmd.addArgument("-q:v");
cmd.addArgument("6");
cmd.addArgument("-r");
cmd.addArgument("25");
cmd.addArgument("-flags");
cmd.addArgument("+loop");
cmd.addArgument("-crf");
cmd.addArgument("24");
cmd.addArgument("-bt");
cmd.addArgument("256k");
cmd.addArgument("-af");
cmd.addArgument("volume=2");
cmd.addArgument("-y");
cmd.addArgument(outFile);
return CmdHelper.run(cmd, cmdOut, 0);
}

CmdHandler video2jpg(String srcFile, String outFile, OutputStream cmdOut) {
CommandLine cmd = new CommandLine(convertConfig.getFfmpeg());
cmd.addArgument("-i");
cmd.addArgument(srcFile);
cmd.addArgument("-f");
cmd.addArgument("image2");
cmd.addArgument("-ss");
cmd.addArgument("15");
cmd.addArgument("-t");
cmd.addArgument("0.001");
cmd.addArgument("-s");
cmd.addArgument("64x64");
cmd.addArgument("-y");
cmd.addArgument(outFile);
return CmdHelper.run(cmd, cmdOut, 0);
}

CmdHandler video2avi(String srcFile, String outFile, OutputStream cmdOut) {
CommandLine cmd = new CommandLine(convertConfig.getMencoder());
cmd.addArgument(srcFile);
cmd.addArgument("-oac");
cmd.addArgument("mp3lame");
cmd.addArgument("-lameopts");
cmd.addArgument("preset=64");
cmd.addArgument("-ovc");
cmd.addArgument("xvid");
cmd.addArgument("-xvidencopts");
cmd.addArgument("bitrate=600");
cmd.addArgument("-of");
cmd.addArgument("avi");
cmd.addArgument("-o");
cmd.addArgument(outFile);
return CmdHelper.run(cmd, cmdOut, 0);
}
}

配置项

1
2
3
4
5
6
7
convert.src=/mnt/file/upload/
convert.out=/mnt/file/download/
convert.log=/mnt/file/download/
convert.ffmpeg=/opt/ffmpeg/ffmpeg
convert.unoconv=/opt/libreoffice6.4/unoconv.py
convert.mencoder=/usr/bin/mencoder
convert.notify=http://127.0.0.1:8080/api/convert/notify
1
2
3
4
5
6
7
8
9
10
11
12
@Data
@Configuration
@ConfigurationProperties(prefix = "convert")
public class ConvertConfig {
String src;
String out;
String log;
String ffmpeg;
String unoconv;
String mencoder;
String notify;
}

依赖

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.3</version>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.9</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

ffmpeg && mencoder

1
2
3
4
5
wget https://download1.rpmfusion.org/free/el/rpmfusion-free-release-7.noarch.rpm
yum install epel-release
rpm -Uvh rpmfusion-free-release-7.noarch.rpm
wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz
yum install mencoder

libreoffice && unoconv

1
2
https://www.libreoffice.org/donate/dl/rpm-x86_64/6.4.4/zh-CN/LibreOffice_6.4.4_Linux_x86-64_rpm.tar.gz
https://codeload.github.com/unoconv/unoconv/zip/0.8.2