Commit f67985dc2a517fbf261dda5c686d93cf169aeb80

Authored by huanggang
1 parent 8e38b431

forbit duplicate submit

cashier-boss/src/main/java/com/diligrp/cashier/boss/BossConfiguration.java
1 1 package com.diligrp.cashier.boss;
2 2  
  3 +import com.diligrp.cashier.shared.http.DuplicateSubmitFilter;
3 4 import com.diligrp.cashier.shared.mybatis.MybatisMapperSupport;
4 5 import com.diligrp.cashier.shared.service.LifeCycle;
5 6 import jakarta.annotation.Resource;
... ... @@ -11,8 +12,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
11 12 import org.springframework.context.annotation.Bean;
12 13 import org.springframework.context.annotation.ComponentScan;
13 14 import org.springframework.context.annotation.Configuration;
  15 +import org.springframework.web.filter.GenericFilterBean;
14 16  
15 17 import java.util.List;
  18 +import java.util.concurrent.TimeUnit;
16 19  
17 20 @Configuration
18 21 @ComponentScan("com.diligrp.cashier.boss")
... ... @@ -28,6 +31,17 @@ public class BossConfiguration implements ApplicationRunner {
28 31 return new CashierDeskProperties();
29 32 }
30 33  
  34 + @Bean
  35 + public GenericFilterBean duplicateSubmitFilter() {
  36 + // 配置防重复提交过滤器
  37 + DuplicateSubmitFilter.Builder builder = new DuplicateSubmitFilter.Builder();
  38 + builder.requestMatcher("/payment/cashier/submitOrder")
  39 + .forbidSubmit(1, TimeUnit.SECONDS)
  40 + .requestMatchers("/payment/cashier/orderPayment", "/payment/cashier/orderRefund")
  41 + .forbidSubmit(1200, TimeUnit.MILLISECONDS);
  42 + return builder.build();
  43 + }
  44 +
31 45 @Override
32 46 public void run(ApplicationArguments args) throws Exception {
33 47 List<LifeCycle> lifeCycles = lifeCycleProvider.stream().toList();
... ...
cashier-boss/src/main/java/com/diligrp/cashier/boss/controller/WechatPaymentController.java
1 1 package com.diligrp.cashier.boss.controller;
2 2  
3 3 import com.diligrp.cashier.boss.exception.BossServiceException;
4   -import com.diligrp.cashier.boss.util.HttpUtils;
5 4 import com.diligrp.cashier.pipeline.core.WechatPartnerPipeline;
6 5 import com.diligrp.cashier.pipeline.core.WechatPipeline;
7 6 import com.diligrp.cashier.pipeline.domain.OnlinePaymentResponse;
... ... @@ -17,6 +16,7 @@ import com.diligrp.cashier.pipeline.util.WechatStateUtils;
17 16 import com.diligrp.cashier.shared.ErrorCode;
18 17 import com.diligrp.cashier.shared.domain.Message;
19 18 import com.diligrp.cashier.shared.util.DateUtils;
  19 +import com.diligrp.cashier.shared.util.HttpUtils;
20 20 import com.diligrp.cashier.shared.util.JsonUtils;
21 21 import com.diligrp.cashier.trade.model.OnlinePayment;
22 22 import com.diligrp.cashier.trade.service.ICashierPaymentService;
... ...
cashier-boss/src/main/java/com/diligrp/cashier/boss/util/HttpUtils.java deleted 100644 → 0
1   -package com.diligrp.cashier.boss.util;
2   -
3   -import com.diligrp.cashier.boss.Constants;
4   -import jakarta.servlet.http.HttpServletRequest;
5   -import jakarta.servlet.http.HttpServletResponse;
6   -import org.slf4j.Logger;
7   -import org.slf4j.LoggerFactory;
8   -
9   -import java.io.BufferedReader;
10   -import java.io.IOException;
11   -import java.nio.charset.StandardCharsets;
12   -
13   -/**
14   - * HTTP工具类
15   - */
16   -public final class HttpUtils {
17   -
18   - private static final Logger LOG = LoggerFactory.getLogger(HttpUtils.class);
19   -
20   - public static String httpBody(HttpServletRequest request) {
21   - StringBuilder payload = new StringBuilder();
22   - try {
23   - String line;
24   - BufferedReader reader = request.getReader();
25   - while ((line = reader.readLine()) != null) {
26   - payload.append(line);
27   - }
28   - } catch (IOException iex) {
29   - LOG.error("Failed to extract http body", iex);
30   - }
31   -
32   - return payload.toString();
33   - }
34   -
35   - public static void sendResponse(HttpServletResponse response, String payload) {
36   - try {
37   - response.setContentType(Constants.CONTENT_TYPE);
38   - byte[] responseBytes = payload.getBytes(StandardCharsets.UTF_8);
39   - response.setContentLength(responseBytes.length);
40   - response.getOutputStream().write(responseBytes);
41   - response.flushBuffer();
42   - } catch (IOException iex) {
43   - LOG.error("Failed to write data packet back");
44   - }
45   - }
46   -}
cashier-shared/src/main/java/com/diligrp/cashier/shared/Constants.java
1 1 package com.diligrp.cashier.shared;
2 2  
3 3 public final class Constants {
4   - public static final String SIGN_ALGORITHM = "SHA1WithRSA";
  4 + public static final String DUPLICATE_SUBMIT_KEY = "cashier:security:resubmit:%s";
5 5  
6 6 public static final String DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
7 7  
... ... @@ -9,10 +9,6 @@ public final class Constants {
9 9  
10 10 public static final String TIME_FORMAT = "HH:mm:ss";
11 11  
12   - public static final int CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors() + 1;
13   -
14   - public static final int MAX_POOL_SIZE = 200;
15   -
16 12 public final static String CONTENT_TYPE = "application/json;charset=UTF-8";
17 13  
18 14 public final static String PRODUCT_NAME = "cashier:";
... ...
cashier-shared/src/main/java/com/diligrp/cashier/shared/codec/ByteCodec.java
1 1 package com.diligrp.cashier.shared.codec;
2 2  
  3 +import java.io.IOException;
  4 +
3 5 public interface ByteCodec<T> {
4 6  
5   - T decode(byte[] payload);
  7 + T decode(byte[] payload) throws IOException;
6 8  
7   - byte[] encode(T payload);
  9 + byte[] encode(T payload) throws IOException;
8 10 }
... ...
cashier-shared/src/main/java/com/diligrp/cashier/shared/codec/ObjectCodec.java 0 → 100644
  1 +package com.diligrp.cashier.shared.codec;
  2 +
  3 +import java.io.*;
  4 +
  5 +public final class ObjectCodec implements ByteCodec<Object> {
  6 +
  7 + public static ByteCodec<Object> INSTANCE = new ObjectCodec();
  8 +
  9 + @Override
  10 + public Object decode(byte[] payload) throws IOException {
  11 + try (ObjectInputStream is = new ObjectInputStream(new ByteArrayInputStream(payload))) {
  12 + return is.readObject();
  13 + } catch (Exception ex) {
  14 + throw new IOException(ex);
  15 + }
  16 + }
  17 +
  18 + @Override
  19 + public byte[] encode(Object payload) throws IOException {
  20 + ByteArrayOutputStream buffer = new ByteArrayOutputStream();
  21 + ObjectOutputStream os = new ObjectOutputStream(buffer);
  22 + os.writeObject(payload);
  23 + os.close();
  24 + return buffer.toByteArray();
  25 + }
  26 +}
... ...
cashier-shared/src/main/java/com/diligrp/cashier/shared/http/AntPathRequestMatcher.java 0 → 100644
  1 +package com.diligrp.cashier.shared.http;
  2 +
  3 +import jakarta.servlet.http.HttpServletRequest;
  4 +import org.springframework.http.HttpMethod;
  5 +import org.springframework.util.AntPathMatcher;
  6 +import org.springframework.util.Assert;
  7 +import org.springframework.util.PathMatcher;
  8 +import org.springframework.util.StringUtils;
  9 +import org.springframework.web.util.UrlPathHelper;
  10 +
  11 +public class AntPathRequestMatcher implements HttpRequestMatcher {
  12 +
  13 + private final String pattern;
  14 +
  15 + private final HttpMethod httpMethod;
  16 +
  17 + private final PathMatcher matcher;
  18 +
  19 + private final UrlPathHelper urlPathHelper;
  20 +
  21 + public AntPathRequestMatcher(String pattern, HttpMethod httpMethod, boolean caseSensitive) {
  22 + Assert.hasText(pattern, "Pattern cannot be null or empty");
  23 + this.pattern = pattern;
  24 + this.httpMethod = httpMethod;
  25 + this.matcher = createPathMatcher(caseSensitive);
  26 + this.urlPathHelper = new UrlPathHelper();
  27 + }
  28 +
  29 + @Override
  30 + public boolean matches(HttpServletRequest request) {
  31 + if (this.httpMethod != null && StringUtils.hasText(request.getMethod()) &&
  32 + this.httpMethod != HttpMethod.valueOf(request.getMethod())) {
  33 + return false;
  34 + }
  35 + if (this.pattern.equals("/**")) {
  36 + return true;
  37 + }
  38 +
  39 + String url = this.urlPathHelper.getPathWithinApplication(request);
  40 + return this.matcher.match(this.pattern, url);
  41 + }
  42 +
  43 + private PathMatcher createPathMatcher(boolean caseSensitive) {
  44 + AntPathMatcher pathMatcher = new AntPathMatcher();
  45 + pathMatcher.setTrimTokens(false);
  46 + pathMatcher.setCaseSensitive(caseSensitive);
  47 + return pathMatcher;
  48 + }
  49 +}
... ...
cashier-shared/src/main/java/com/diligrp/cashier/shared/http/DuplicateSubmitFilter.java 0 → 100644
  1 +package com.diligrp.cashier.shared.http;
  2 +
  3 +import com.diligrp.cashier.shared.Constants;
  4 +import com.diligrp.cashier.shared.ErrorCode;
  5 +import com.diligrp.cashier.shared.codec.StringCodec;
  6 +import com.diligrp.cashier.shared.domain.Message;
  7 +import com.diligrp.cashier.shared.exception.PlatformServiceException;
  8 +import com.diligrp.cashier.shared.security.Md5Cipher;
  9 +import com.diligrp.cashier.shared.util.HttpUtils;
  10 +import com.diligrp.cashier.shared.util.JsonUtils;
  11 +import jakarta.annotation.Resource;
  12 +import jakarta.servlet.FilterChain;
  13 +import jakarta.servlet.ServletException;
  14 +import jakarta.servlet.ServletRequest;
  15 +import jakarta.servlet.ServletResponse;
  16 +import jakarta.servlet.http.HttpServletRequest;
  17 +import jakarta.servlet.http.HttpServletResponse;
  18 +import org.slf4j.Logger;
  19 +import org.slf4j.LoggerFactory;
  20 +import org.springframework.data.redis.core.StringRedisTemplate;
  21 +import org.springframework.http.HttpMethod;
  22 +import org.springframework.util.Assert;
  23 +import org.springframework.util.ObjectUtils;
  24 +import org.springframework.web.filter.GenericFilterBean;
  25 +import org.springframework.web.util.ContentCachingRequestWrapper;
  26 +
  27 +import java.io.IOException;
  28 +import java.util.ArrayList;
  29 +import java.util.Base64;
  30 +import java.util.Collections;
  31 +import java.util.List;
  32 +import java.util.concurrent.TimeUnit;
  33 +
  34 +/**
  35 + * 防重复提交过滤器
  36 + */
  37 +public class DuplicateSubmitFilter extends GenericFilterBean {
  38 +
  39 + private static final Logger LOG = LoggerFactory.getLogger(DuplicateSubmitFilter.class);
  40 +
  41 + private final List<RequestMapping> mappings;
  42 +
  43 + @Resource
  44 + private StringRedisTemplate stringRedisTemplate;
  45 +
  46 + public DuplicateSubmitFilter(List<RequestMapping> mappings) {
  47 + this.mappings = mappings;
  48 + }
  49 +
  50 + @Override
  51 + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
  52 + HttpServletRequest httpRequest = (HttpServletRequest) request;
  53 + for (RequestMapping mapping : this.mappings) {
  54 + try {
  55 + if (mapping.match((HttpServletRequest) request)) {
  56 + LOG.debug("{} filtered", this.getClass().getSimpleName());
  57 + // 缓存当前请求以便可以重复读取请求数据
  58 + request = new ContentCachingRequestWrapper(httpRequest);
  59 + String requestId = requestId((ContentCachingRequestWrapper) request);
  60 + String requestKey = String.format(Constants.DUPLICATE_SUBMIT_KEY, requestId);
  61 + Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(requestKey,
  62 + requestId, mapping.getDuration(), mapping.getTimeUnit());
  63 + if (success == null || !success) {
  64 + Message<?> message = Message.failure(ErrorCode.OPERATION_NOT_ALLOWED, "请求重复提交,访问被拒绝");
  65 + HttpUtils.sendResponse((HttpServletResponse) response, JsonUtils.toJsonString(message));
  66 + return;
  67 + }
  68 + }
  69 + } catch (PlatformServiceException ex) {
  70 + throw ex;
  71 + } catch (Exception ex) {
  72 + LOG.error("防重复提交过滤器处理失败", ex);
  73 + Message<?> message = Message.failure(ErrorCode.SYSTEM_UNKNOWN_ERROR, "防重复提交过滤器处理失败");
  74 + HttpUtils.sendResponse((HttpServletResponse) response, JsonUtils.toJsonString(message));
  75 + return;
  76 + }
  77 + }
  78 +
  79 + chain.doFilter(request, response);
  80 + }
  81 +
  82 + @Override
  83 + public void afterPropertiesSet() throws ServletException {
  84 + super.afterPropertiesSet();
  85 + Assert.notEmpty(mappings, "duplicate submit request setting must be specified");
  86 + }
  87 +
  88 + public static class RequestMapping {
  89 + private final HttpRequestMatcher requestMatcher;
  90 +
  91 + private final long duration;
  92 +
  93 + private final TimeUnit timeUnit;
  94 +
  95 + public RequestMapping(HttpRequestMatcher requestMatcher, long duration, TimeUnit timeUnit) {
  96 + this.requestMatcher = requestMatcher;
  97 + this.duration = duration;
  98 + this.timeUnit = timeUnit;
  99 + }
  100 +
  101 + public boolean match(HttpServletRequest request) {
  102 + return this.requestMatcher.matches(request);
  103 + }
  104 +
  105 + public long getDuration() {
  106 + return duration;
  107 + }
  108 +
  109 + public TimeUnit getTimeUnit() {
  110 + return timeUnit;
  111 + }
  112 + }
  113 +
  114 + private String requestId(ContentCachingRequestWrapper request) throws Exception {
  115 + String requestURI = request.getRequestURI();
  116 + String queryString = request.getQueryString();
  117 + if (!ObjectUtils.isEmpty(queryString)) {
  118 + requestURI = requestURI.concat("?").concat(queryString);
  119 + }
  120 +
  121 + byte[] uri = StringCodec.INSTANCE.encode(requestURI);
  122 + byte[] body = request.getContentAsByteArray();
  123 + byte[] data = new byte[uri.length + body.length];
  124 + System.arraycopy(uri, 0, data, 0, uri.length);
  125 + System.arraycopy(body, 0, data, uri.length, body.length);
  126 +
  127 + return Base64.getEncoder().encodeToString(Md5Cipher.encrypt(data));
  128 + }
  129 +
  130 + public static class Builder {
  131 +
  132 + private final List<RequestMapping> mappings = new ArrayList<>();
  133 +
  134 + public RequestMappingBuilder requestMatcher(String pattern) {
  135 + return requestMatcher(null, pattern);
  136 + }
  137 +
  138 + public RequestMappingBuilder requestMatcher(HttpMethod method, String pattern) {
  139 + Assert.hasText(pattern, "patterns must not be empty");
  140 + return new RequestMappingBuilder(new AntPathRequestMatcher(pattern, method, true));
  141 + }
  142 +
  143 + public RequestMappingBuilder requestMatchers(String... patterns) {
  144 + return requestMatchers(null, patterns);
  145 + }
  146 +
  147 + public RequestMappingBuilder requestMatchers(HttpMethod method, String... patterns) {
  148 + Assert.notEmpty(patterns, "patterns must not be empty");
  149 + List<HttpRequestMatcher> requestMatchers = new ArrayList<>(patterns.length);
  150 + for (String pattern : patterns) {
  151 + requestMatchers.add(new AntPathRequestMatcher(pattern, method, true));
  152 + }
  153 + return new RequestMappingBuilder(new OrRequestMatcher(requestMatchers));
  154 + }
  155 +
  156 + public DuplicateSubmitFilter build() {
  157 + return new DuplicateSubmitFilter(Collections.unmodifiableList(this.mappings));
  158 + }
  159 +
  160 + public class RequestMappingBuilder {
  161 + private final HttpRequestMatcher requestMatcher;
  162 +
  163 + public RequestMappingBuilder(HttpRequestMatcher requestMatcher) {
  164 + this.requestMatcher = requestMatcher;
  165 + }
  166 +
  167 + public Builder forbidSubmit(long duration, TimeUnit timeUnit) {
  168 + Assert.isTrue(duration > 0, "Invalid duration");
  169 + Assert.notNull(timeUnit, "timeUnit must not be null");
  170 + Builder.this.mappings.add(new RequestMapping(requestMatcher, duration, timeUnit));
  171 + return Builder.this;
  172 + }
  173 + }
  174 + }
  175 +}
... ...
cashier-shared/src/main/java/com/diligrp/cashier/shared/http/HttpRequestMatcher.java 0 → 100644
  1 +package com.diligrp.cashier.shared.http;
  2 +
  3 +import jakarta.servlet.http.HttpServletRequest;
  4 +
  5 +public interface HttpRequestMatcher {
  6 + boolean matches(HttpServletRequest request);
  7 +}
... ...
cashier-shared/src/main/java/com/diligrp/cashier/shared/http/OrRequestMatcher.java 0 → 100644
  1 +package com.diligrp.cashier.shared.http;
  2 +
  3 +import jakarta.servlet.http.HttpServletRequest;
  4 +
  5 +import java.util.Arrays;
  6 +import java.util.List;
  7 +
  8 +public class OrRequestMatcher implements HttpRequestMatcher {
  9 + private final List<HttpRequestMatcher> requestMatchers;
  10 +
  11 + public OrRequestMatcher(List<HttpRequestMatcher> requestMatchers) {
  12 + this.requestMatchers = requestMatchers;
  13 + }
  14 +
  15 + public OrRequestMatcher(HttpRequestMatcher... requestMatchers) {
  16 + this(Arrays.asList(requestMatchers));
  17 + }
  18 +
  19 + @Override
  20 + public boolean matches(HttpServletRequest request) {
  21 + for (HttpRequestMatcher matcher : this.requestMatchers) {
  22 + if (matcher.matches(request)) {
  23 + return true;
  24 + }
  25 + }
  26 + return false;
  27 + }
  28 +
  29 + @Override
  30 + public String toString() {
  31 + return "Or " + this.requestMatchers;
  32 + }
  33 +}
... ...
cashier-trade/src/main/java/com/diligrp/cashier/trade/manager/PaymentResultManager.java
... ... @@ -76,7 +76,7 @@ public class PaymentResultManager {
76 76 * 通知业务系统退款处理结果
77 77 */
78 78 public void notifyRefundResult(String uri, OnlineRefundResult refundResult) {
79   - LOG.info("Notifying online payment result: {}, {}", refundResult.getTradeId(), refundResult.getRefundId());
  79 + LOG.info("Notifying online refund result: {}, {}", refundResult.getTradeId(), refundResult.getRefundId());
80 80 ThreadPoolService.getIoThreadPoll().submit(() -> {
81 81 List<IPaymentEventListener> lifeCycles = eventListeners.stream().toList();
82 82 if (!lifeCycles.isEmpty()) {
... ... @@ -86,7 +86,7 @@ public class PaymentResultManager {
86 86 try {
87 87 listener.onEvent(refundEvent);
88 88 } catch (Exception ex) {
89   - LOG.error("Failed to notify payment refund result", ex);
  89 + LOG.error("Failed to notify online refund result", ex);
90 90 }
91 91 }
92 92 }
... ... @@ -95,13 +95,13 @@ public class PaymentResultManager {
95 95 ThreadPoolService.getIoThreadPoll().submit(() -> {
96 96 try {
97 97 String payload = JsonUtils.toJsonString(refundResult);
98   - LOG.info("Notifying online payment refund result: {}", payload);
  98 + LOG.info("Notifying online refund result: {}", payload);
99 99 ServiceEndpointSupport.HttpResult httpResult = new NotifyHttpClient(uri).send(payload);
100 100 if (httpResult.statusCode != 200) {
101   - LOG.error("Failed to notify payment refund result");
  101 + LOG.error("Failed to notify online refund result");
102 102 }
103 103 } catch (Exception ex) {
104   - LOG.error("Failed to notify payment refund result", ex);
  104 + LOG.error("Failed to notify online refund result", ex);
105 105 }
106 106 });
107 107 }
... ...