专注app软件定制开发SpringCloud Gateway 网关的请求体body的读取和修改

1. 需求背景

        Gateway 专注app软件定制开发作为微服务集群的入口,专注app软件定制开发除了进行一些权限验证、header封装以外,专注app软件定制开发可能也需要对请求体body进行封装。

        专注app软件定制开发比如随着业务子系统的扩展,专注app软件定制开发各子系统的请求体body专注app软件定制开发格式各不一致,例如:子系统A专注app软件定制开发的请求体入参要求是Json专注app软件定制开发格式体既可:{"name":"aaa"},专注app软件定制开发但是子系统B请求体入参要求是Json格式体,但是因为历史原因,虽然也是要求Json格式,但是在最外层进行一层封装,格式为:{body: 实际的json},这个封装的操作就可以在Gateway 的过滤器中进行封装。

        再比如:我们在里面需要读取到原始请求体的入参,其中包括json格式和文件上传类型的入参,获取到这里入参后需要进行一些签名处理后,保存在header中。这种情况下,我们就需要针对不同的请求类型的请求体进行缓存。而不能全部当作json字符串请求体进行缓存。

2. 具体方法

2.1 request body 只能读取一次问题

        在Gateway中通常会有一个过滤器链,而 request body 只能读取一次,也就是说,如果在过滤器A中已经读取一次,在后面的过滤器B是无法读取成功的,会抛出如下的报错:

  1. java.lang.IllegalStateException: Only one connection receive subscriber allowed.
  2. at reactor.ipc.netty.channel.FluxReceive.startReceiver(FluxReceive.java:279)
  3. at reactor.ipc.netty.channel.FluxReceive.lambda$subscribe$2(FluxReceive.java:129)
  4. at io.netty.util.concurrent.AbstractEventExecutor.safeExecute$$$capture(AbstractEventExecutor.java:163)
  5. at io.netty.util.concurrent.AbstractEventExecutor.safeExecute(AbstractEventExecutor.java)
  6. at io.netty.util.concurrent.SingleThreadEventExecutor.runAllTasks(SingleThreadEventExecutor.java:404)
  7. at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:446)
  8. at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:884)
  9. at java.lang.Thread.run(Thread.java:745)

大意就是netty的request body只能读取一次,第二次读取就报这个错误了。

问题原因

        翻查GitHub终于找到,spring boot在2.0.5版本如果使用了WebFlux就自动配置HiddenHttpMethodFilter过滤器。
查看源码发现,这个过滤器的作用是,针对当前的浏览器一般只支持GETPOST表单提交方法,如果想使用其他HTTP方法(如:PUT、DELETE、PATCH),就只能通过一个隐藏的属性如(_method=PUT)来表示,那么HiddenHttpMethodFilter的作用是将POST请求的_method参数里面的value替换掉http请求的方法。
        想法是很好的,用一种折中的方法来支持使浏览器支持restful方法。

        如果只是使用spring boot,一切都是没有问题的,因为使用的过程中,不需要我们自己解析request body,到controller这一层,这一切就已经完成的了。

        但是spring cloud gateway需要,因为它的做法就是拿到原始请求信息(包括request body),再重新封装一个request路由到下游,所以上面的问题就在于:

  1. HiddenHttpMethodFilter读取了一次request body;

  2. gateway的封装自己的request时,去读取request body,就报错了。

所以这个是spring cloud gateway和spring boot开发者没协商好,都去读取request body的问题。

问题解决方案

  1. HiddenHttpMethodFilter是spring boot在2.0.5版本自动引入的,将版本降到2.0.4即可
  2. 在不降版本的前提下,增加一个缓存请求体过滤器 CacheBodyGlobalFilter ,将其执行优先级设置最大(order值最小),使其在过滤器链中最先执行。

 2.2 缓存请求体过滤器

实际工作中,post请求通常是分为两种,一种是json请求类型(ContentType=application/json),还有一种是上传文件类型的form表单(ContentType=multipart/form-data),可以根据请求类型的不同,分别缓存请求体body,所以这里先新建一个GatewayContext类对数据进行缓存

GatewayContext.java

  1. package com.test.filter;
  2. import lombok.Data;
  3. import org.springframework.http.codec.multipart.Part;
  4. import org.springframework.util.MultiValueMap;
  5. @Data
  6. public class GatewayContext {
  7. public static final String CACHE_GATEWAY_CONTEXT = "cacheGatewayContext";
  8. /**
  9. * cache json body
  10. */
  11. private String jsonBody;
  12. /**
  13. *--multipart/form表单参数
  14. */
  15. private MultiValueMap<String, Part> multiPartParams;
  16. }

全局过滤器 CacheBodyGlobalFilter.java

  1. package com.test.filter;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.springframework.cloud.gateway.filter.GatewayFilterChain;
  4. import org.springframework.cloud.gateway.filter.GlobalFilter;
  5. import org.springframework.core.Ordered;
  6. import org.springframework.core.ParameterizedTypeReference;
  7. import org.springframework.core.io.buffer.*;
  8. import org.springframework.http.HttpMethod;
  9. import org.springframework.http.MediaType;
  10. import org.springframework.http.codec.HttpMessageReader;
  11. import org.springframework.http.codec.ServerCodecConfigurer;
  12. import org.springframework.http.codec.multipart.Part;
  13. import org.springframework.http.server.reactive.ServerHttpRequest;
  14. import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
  15. import org.springframework.stereotype.Component;
  16. import org.springframework.util.MultiValueMap;
  17. import org.springframework.web.reactive.function.server.ServerRequest;
  18. import org.springframework.web.server.ServerWebExchange;
  19. import reactor.core.publisher.Flux;
  20. import reactor.core.publisher.Mono;
  21. import java.util.List;
  22. import java.util.Objects;
  23. @Slf4j
  24. @Component
  25. public class CacheBodyGlobalFilter implements Ordered, GlobalFilter {
  26. private List<HttpMessageReader<?>> messageReaders;
  27. private ParameterizedTypeReference<MultiValueMap<String, Part>> MULTI_PART = new ParameterizedTypeReference<MultiValueMap<String, Part>>(){};
  28. public CacheBodyGlobalFilter(ServerCodecConfigurer configurer) {
  29. this.messageReaders = configurer.getReaders();
  30. }
  31. @Override
  32. public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
  33. GatewayContext gatewayContext = new GatewayContext();
  34. exchange.getAttributes().put(GatewayContext.CACHE_GATEWAY_CONTEXT, gatewayContext);
  35. ServerHttpRequest request = exchange.getRequest();
  36. MediaType contentType = request.getHeaders().getContentType();
  37. // 目前只缓存 json 和 multipart 表单两种请求类型
  38. if (Objects.nonNull(contentType) && Objects.nonNull(request.getMethod()) && request.getMethod().equals(HttpMethod.POST)) {
  39. if (MediaType.MULTIPART_FORM_DATA.isCompatibleWith(contentType)) {
  40. return readMultiPartFormData(exchange, chain, gatewayContext);
  41. } else if (MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
  42. return readBody(exchange, chain, gatewayContext);
  43. }
  44. }
  45. return chain.filter(exchange);
  46. }
  47. @Override
  48. public int getOrder() {
  49. return Ordered.HIGHEST_PRECEDENCE;
  50. }
  51. private Mono<Void> readMultiPartFormData(ServerWebExchange exchange, GatewayFilterChain chain, GatewayContext gatewayContext) {
  52. // 当body为空时,只会执行这一个拦截器, 原因是fileMap中的代码没有执行,所以需要在body为空时构建一个空的缓存
  53. DefaultDataBufferFactory defaultDataBufferFactory = new DefaultDataBufferFactory();
  54. DefaultDataBuffer defaultDataBuffer = defaultDataBufferFactory.allocateBuffer(0);
  55. Mono<DataBuffer> mono = Flux.from(exchange.getRequest().getBody().defaultIfEmpty(defaultDataBuffer))
  56. .collectList().filter(list -> {
  57. log.info("请求体缓存过滤器:body为空");
  58. return true;
  59. }).map(list -> list.get(0).factory().join(list)).doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release);
  60. return mono.flatMap(dataBuffer -> {
  61. byte[] bytes = new byte[dataBuffer.readableByteCount()];
  62. dataBuffer.read(bytes);
  63. DataBufferUtils.release(dataBuffer);
  64. ServerHttpRequestDecorator mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
  65. @Override
  66. public Flux<DataBuffer> getBody() {
  67. return Flux.defer(() -> {
  68. DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
  69. DataBufferUtils.retain(buffer);
  70. return Mono.just(buffer);
  71. });
  72. }
  73. };
  74. ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
  75. return ServerRequest.create(mutatedExchange, messageReaders).bodyToMono(MULTI_PART)
  76. .doOnNext(multiPartMap -> {
  77. gatewayContext.setMultiPartParams(multiPartMap);
  78. }).then(chain.filter(mutatedExchange));
  79. });
  80. }
  81. private Mono<Void> readBody(ServerWebExchange exchange, GatewayFilterChain chain, GatewayContext gatewayContext) {
  82. // 当body为空(请求体中"{}"都不存在)时,只会执行这一个拦截器, 原因是fileMap中的代码没有执行,所以需要在body为空时构建一个空的缓存
  83. DefaultDataBufferFactory defaultDataBufferFactory = new DefaultDataBufferFactory();
  84. DefaultDataBuffer defaultDataBuffer = defaultDataBufferFactory.allocateBuffer(0);
  85. Mono<DataBuffer> mono = Flux.from(exchange.getRequest().getBody().defaultIfEmpty(defaultDataBuffer))
  86. .collectList().filter(list -> {
  87. log.info("请求体缓存过滤器:body为空");
  88. return true;
  89. }).map(list -> list.get(0).factory().join(list)).doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release);
  90. return mono.flatMap(dataBuffer -> {
  91. byte[] bytes = new byte[dataBuffer.readableByteCount()];
  92. dataBuffer.read(bytes);
  93. DataBufferUtils.release(dataBuffer);
  94. ServerHttpRequestDecorator mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
  95. @Override
  96. public Flux<DataBuffer> getBody() {
  97. return Flux.defer(() -> {
  98. DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
  99. DataBufferUtils.retain(buffer);
  100. return Mono.just(buffer);
  101. });
  102. }
  103. };
  104. ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
  105. return ServerRequest.create(mutatedExchange, messageReaders)
  106. .bodyToMono(String.class)
  107. .doOnNext(objectValue -> {
  108. gatewayContext.setJsonBody(objectValue);
  109. }).then(chain.filter(mutatedExchange));
  110. });
  111. }
  112. }

CacheBodyGlobalFilter这个全局过滤器的目的就是把原有的request请求中的body内容读出来,并且使用ServerHttpRequestDecorator这个请求装饰器对request进行包装,重写getBody方法,并把包装后的请求放到过滤器链中传递下去。这样后面的过滤器中再使用exchange.getRequest().getBody()来获取body时,实际上就是调用的重载后的getBody方法,获取的最先已经缓存了的body数据。这样就能够实现body的多次读取了。
这个过滤器的order设置的是Ordered.HIGHEST_PRECEDENCE,即最高优先级的过滤器。优先级设置这么高的原因是某些系统内置的过滤器可能也会去读body。

说一下代码中对于body请求体为空的处理。

  1. // 当body为空时,只会执行这一个拦截器, 原因是fileMap中的代码没有执行,所以需要在body为空时构建一个空的缓存
  2. DefaultDataBufferFactory defaultDataBufferFactory = new DefaultDataBufferFactory();
  3. DefaultDataBuffer defaultDataBuffer = defaultDataBufferFactory.allocateBuffer(0);

        测试中我发现,如果我在请求接口中如果没有body内容,就会导致程序只能执行CacheBodyGlobalFilter这一个拦截器,而无法执行其他拦截器(自定义的和默认的)。而且接口返回200,这和我的预期时不一致的。

         通过测试发现,原因是,按照以上代码执行,如果是body为null,Conten-Type也为空,所以没有执行代码中的flatMap()方法.所以也就没有执行后面的调用链。

        解决办法,在获取到数据流时,如果数据流为null,我们可以构建一个空的数据流,这也也就能执行我们后面的拦截器。利用Flux.defaultIfEmpty(defaultDataBuffer);的方法可以实现这个功能。

2.3 后续过滤器中读取缓存的body

以读取请求体中的入参进行签名为例:对于json请求类型,直接对所有入参取出放进签名工具类进行签名操作;而对于文件上传类型的表单multipart/form-data,将除了文件类型file的入参以外,其他的所有入参取出来进行签名。

从GatewayContext的缓存中读取请求体,使用GatewayContext gatewayContext = exchange.getAttribute(GatewayContext.CACHE_GATEWAY_CONTEXT);既可,因为在全局过滤器中已经使用 exchange.getAttributes().put(GatewayContext.CACHE_GATEWAY_CONTEXT, gatewayContext); 保存过。

  1. // 2.2 获取请求入参
  2. Map<String, String> params = new HashMap<>(exchange.getRequest().getQueryParams().entrySet().size());
  3. exchange.getRequest().getQueryParams().forEach((key, valueList) -> {
  4. params.put(key, valueList.stream().findFirst().get());
  5. });
  6. log.info("签名处理 - 读取到的请求入参为:{}", params);
  7. // 2.3 对post请求体中的入参进行签名
  8. GatewayContext gatewayContext = exchange.getAttribute(GatewayContext.CACHE_GATEWAY_CONTEXT);
  9. String jsonBody = null;
  10. MediaType contentType = exchange.getRequest().getHeaders().getContentType();
  11. if (Objects.nonNull(contentType) && Objects.nonNull(exchange.getRequest().getMethod())
  12. && exchange.getRequest().getMethod().equals(HttpMethod.POST)) {
  13. if (MediaType.MULTIPART_FORM_DATA.isCompatibleWith(contentType)) {
  14. // 文件表单类型读取除file外字段进行签名
  15. Map<String, String> paramsMap = this.readFormSignBody(gatewayContext.getMultiPartParams());
  16. params.putAll(paramsMap);
  17. } else if (MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
  18. jsonBody = gatewayContext.getJsonBody();
  19. }
  20. }

具体的从form表单中读取入参的方法如下:

  1. private Map<String, String> readFormSignBody(MultiValueMap<String, Part> multiPartParams) {
  2. Map<String, String> params = Maps.newHashMap();
  3. if (Objects.nonNull(multiPartParams) && !multiPartParams.isEmpty()) {
  4. for(Map.Entry<String, List<Part>> entry : multiPartParams.entrySet()) {
  5. String key = entry.getKey();
  6. List<Part> value = entry.getValue();
  7. if (StringUtils.isBlank(key) || CollectionUtils.isEmpty(value)) {
  8. continue;
  9. }
  10. for (Part part : entry.getValue()) {
  11. // 文件不参与签名
  12. if (part instanceof FilePart) {
  13. continue;
  14. }
  15. if (!(part instanceof FormFieldPart)) {
  16. log.error("multipart/formdata Part 类型即不是file也不是formfield,class - {}!", part.getClass().getCanonicalName());
  17. continue;
  18. }
  19. AtomicReference<String> valueHolder = new AtomicReference<String>();
  20. part.content().subscribe(buffer -> {
  21. byte[] datas = new byte[buffer.readableByteCount()];
  22. buffer.read(datas);
  23. DataBufferUtils.release(buffer);
  24. if (ArrayUtil.isNotEmpty(datas)) {
  25. String paramValue = new String(datas);
  26. if (StringUtils.isNotEmpty(paramValue)) {
  27. valueHolder.set(paramValue);
  28. }
  29. }
  30. });
  31. params.put(key, valueHolder.get());
  32. }
  33. }
  34. }
  35. return params;
  36. }

将读取到的json请求体或者form表单请求入参,使用签名工具进行签名处理。

  1. log.info("签名处理 - 读取到的 body 入参为: {}", jsonBody);
  2. String sign = ThirdUserCenterSignUtil.sign(saleAssistSignSecret, headersMap, params, jsonBody);
  3. log.info("最终生成的签名为: {}", sign);
  4. // 获取签名后,将签名值保存到请求头header中
  5. exchange.getRequest().mutate().header(ThirdHeaderSignEnum.X_VALIDATE_SIGN.getCode(), sign);

2.4 过滤器2中对请求体进行修改

以对json请求类型的请求体进行封装为例,如:原json请求体为:{"name" : "xxx"},现在需要封装成为{"body" : 原json},即:{"body" : "{"name" : "xxx"}"} 格式,可以使用以下方式进行封装:

  1. import org.apache.commons.collections4.CollectionUtils;
  2. import org.springframework.beans.factory.annotation.Autowired;
  3. import org.springframework.beans.factory.annotation.Value;
  4. import org.springframework.cloud.context.config.annotation.RefreshScope;
  5. import org.springframework.cloud.gateway.filter.GatewayFilterChain;
  6. import org.springframework.cloud.gateway.filter.factory.rewrite.CachedBodyOutputMessage;
  7. import org.springframework.cloud.gateway.support.BodyInserterContext;
  8. import org.springframework.core.io.buffer.DataBuffer;
  9. import org.springframework.core.io.buffer.DataBufferUtils;
  10. import org.springframework.http.HttpHeaders;
  11. import org.springframework.http.HttpMethod;
  12. import org.springframework.http.HttpStatus;
  13. import org.springframework.http.MediaType;
  14. import org.springframework.http.codec.multipart.FilePart;
  15. import org.springframework.http.codec.multipart.FormFieldPart;
  16. import org.springframework.http.codec.multipart.Part;
  17. import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
  18. import org.springframework.stereotype.Component;
  19. import org.springframework.util.MultiValueMap;
  20. import org.springframework.web.reactive.function.BodyInserter;
  21. import org.springframework.web.reactive.function.BodyInserters;
  22. import org.springframework.web.reactive.function.server.HandlerStrategies;
  23. import org.springframework.web.reactive.function.server.ServerRequest;
  24. import org.springframework.web.server.ServerWebExchange;
  25. import org.springframework.core.Ordered;
  26. import reactor.core.publisher.Flux;
  27. import reactor.core.publisher.Mono;
  28. import java.util.HashMap;
  29. import java.util.List;
  30. import java.util.Map;
  31. import java.util.Objects;
  32. import java.util.concurrent.atomic.AtomicReference;
  33. public class ReadReqBodyFilter2 implements GlobalFilter, Ordered {
  34. public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) implements GlobalFilter, Ordered {
  35. log.info("第三方请求过滤器处理 start");
  36. Mono<Void> mono = chain.filter(exchange);
  37. if (Objects.nonNull(contentType) && Objects.nonNull(exchange.getRequest().getMethod())
  38. && exchange.getRequest().getMethod().equals(HttpMethod.POST)) {
  39. if (MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
  40. // json 请求体处理
  41. mono = this.transferBody(exchange, chain);
  42. }
  43. }
  44. log.info("第三方请求过滤器处理 end");
  45. return mono;
  46. }
  47. @Override
  48. public int getOrder() {
  49. return Ordered.HIGHEST_PRECEDENCE + 100;
  50. }
  51. /**
  52. * 修改原请求体内容
  53. */
  54. private Mono<Void> transferBody(ServerWebExchange exchange, GatewayFilterChain chain) {
  55. log.info("第三方请求过滤器处理 --- 请求体处理 ---- start");
  56. ServerRequest serverRequest = ServerRequest.create(exchange, HandlerStrategies.withDefaults().messageReaders());
  57. Mono modifiedBody = serverRequest.bodyToMono(String.class).flatMap(oldBody -> {
  58. // 对原始请求body进行封装,格式:{ "body": 原始 json 体}
  59. // 当然这里也可以将修改后的请求体覆盖到GatewayContext缓存中,这里没有覆盖是因为想要保留最原始的请求体内容
  60. JSONObject jsonObject = new JSONObject();
  61. jsonObject.put("body", oldBody);
  62. String newBody = jsonObject.toJSONString();
  63. return Mono.just(newBody);
  64. });
  65. BodyInserter bodyInserter = BodyInserters.fromPublisher(modifiedBody, String.class);
  66. HttpHeaders headers = new HttpHeaders();
  67. headers.putAll(exchange.getRequest().getHeaders());
  68. headers.remove(HttpHeaders.CONTENT_LENGTH);
  69. CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, headers);
  70. Mono mono = bodyInserter.insert(outputMessage, new BodyInserterContext())
  71. .then(Mono.defer(() -> {
  72. ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(
  73. exchange.getRequest()) {
  74. @Override
  75. public HttpHeaders getHeaders() {
  76. long contentLength = headers.getContentLength();
  77. HttpHeaders httpHeaders = new HttpHeaders();
  78. httpHeaders.putAll(super.getHeaders());
  79. if (contentLength > 0) {
  80. httpHeaders.setContentLength(contentLength);
  81. } else {
  82. httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
  83. }
  84. return httpHeaders;
  85. }
  86. @Override
  87. public Flux<DataBuffer> getBody() {
  88. return outputMessage.getBody();
  89. }
  90. };
  91. return chain.filter(exchange.mutate().request(decorator).build());
  92. }));
  93. log.info("第三方请求过滤器处理 --- 请求体处理 ---- end");
  94. return mono;
  95. }
  96. }

2.5 过滤器3中最后的过滤器从缓存读取修改后请求体

  1. package com.test.filter;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.springframework.cloud.gateway.filter.GatewayFilterChain;
  4. import org.springframework.cloud.gateway.filter.GlobalFilter;
  5. import org.springframework.core.Ordered;
  6. import org.springframework.core.io.buffer.DataBuffer;
  7. import org.springframework.core.io.buffer.DataBufferUtils;
  8. import org.springframework.stereotype.Component;
  9. import org.springframework.web.server.ServerWebExchange;
  10. import reactor.core.publisher.Flux;
  11. import reactor.core.publisher.Mono;
  12. import java.nio.CharBuffer;
  13. import java.nio.charset.StandardCharsets;
  14. import java.util.concurrent.atomic.AtomicReference;
  15. @Component
  16. @Slf4j
  17. public class ReadReqBodyFilter3 implements GlobalFilter, Ordered {
  18. /**
  19. * 从缓存中读取请求体
  20. */
  21. public String resolveBodyFromRequest(Flux<DataBuffer> body) {
  22. AtomicReference<String> bodyRef = new AtomicReference<>();
  23. // 缓存读取的request body信息
  24. body.subscribe(dataBuffer -> {
  25. CharBuffer charBuffer = StandardCharsets.UTF_8.decode(dataBuffer.asByteBuffer());
  26. DataBufferUtils.release(dataBuffer);
  27. bodyRef.set(charBuffer.toString());
  28. });
  29. return bodyRef.get();
  30. }
  31. @Override
  32. public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
  33. log.info("过滤器3从缓存中读取修改后请求体body start");
  34. String signBody = this.resolveBodyFromRequest(exchange.getRequest().getBody());
  35. log.info("过滤器3从缓存中读取修改后请求体body end", signBody);
  36. return chain.filter(exchange);
  37. }
  38. @Override
  39. public int getOrder() {
  40. return 100;
  41. }
  42. }

从过滤器3的日志就可以看出:原始的请求体已经被过滤器1修改了: {body: 原始请求体json}。

参考文献

网站建设定制开发 软件系统开发定制 定制软件开发 软件开发定制 定制app开发 app开发定制 app开发定制公司 电商商城定制开发 定制小程序开发 定制开发小程序 客户管理系统开发定制 定制网站 定制开发 crm开发定制 开发公司 小程序开发定制 定制软件 收款定制开发 企业网站定制开发 定制化开发 android系统定制开发 定制小程序开发费用 定制设计 专注app软件定制开发 软件开发定制定制 知名网站建设定制 软件定制开发供应商 应用系统定制开发 软件系统定制开发 企业管理系统定制开发 系统定制开发