微信第三方平台小程序平台设计
今天是2023年1月15日,距离2023春节倒计时7天。在此,我分享一下个人对于微信第三方平台小程序的理解以及搭建一个微信小程序及云端服务的一些个人经验,作为交流。
首先,一个第三方平台小程序要定位是面向什么行业,不同的行业顶层设计差别很大。
我的这个第三方平台小程序是面向第三方商家提供的在线下单服务。如:鲜花实体门店的在线下单购买及短距离配配送、餐饮的扫码点餐、小型工厂的自营商城等、有在线下单支付需求的连锁店等。
定位好产品业务范围后,接下来需要整体规划架构设计。架构设计好比是一座大厦的地基部分,设计不好不利于业务的开展。
第二步: 架构设计部分,我简单作个介绍。我会着重从网关、鉴权体系、高可用、高并发设计几个方面展开。
1 架构设计
架构设计由SLB、网关、注册/配置中心、微信、基础设施几部分组成。
其中:SLB: 可购买SLB弹性负载均衡服务,也可以自建,具体就是安装NGINX服务,将域名的后端流量转发至网关。 在此,将NGINX配置文件nginx.conf 贴出来,供参考:
user root;
worker_processes 1;error_log /var/logs/nginx/error.log info;
pid /var/pids/nginx.pid;
events {
use epoll;
worker_connections 1024;
}http {
client_header_buffer_size 32k;
large_client_header_buffers 4 32k;
fastcgi_buffers 8 16k;
fastcgi_buffer_size 32k;
include mime.types;
default_type application/octet-stream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
add_header Content-Security-Policy "upgrade-insecure-requests";
sendfile on;
keepalive_timeout 65;
keepalive_requests 150;
ssl_certificate /usr/home/softwares/cer/nginx.crt;
ssl_certificate_key /usr/home/softwares/cer/nginx.key;
gzip on;
gzip_min_length 1k;
gzip_buffers 16 64k;
gzip_http_version 1.1;
gzip_comp_level 6;
gzip_types text/plain application/x-javascript text/css application/xml image/jpeg image/gif image/png;
gzip_vary on;upstream back {
#sticky;
server 127.0.0.1:9091 weight=1 max_fails=1 fail_timeout=6s;
server 127.0.0.1:9092 weight=1 max_fails=1 fail_timeout=6s;}
# HTTPS server
server {
listen 443 ssl;
listen 80;
#listen 443 default ssl;
server_name XXXX.com;
ssl_certificate /usr/home/softwares/cer/nginx.crt;
ssl_certificate_key /usr/home/softwares/cer/nginx.key;ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;error_page 502 503 /50x.html;
if ($scheme = http) {
return 301 https://$host$request_uri;
}
location = /50x.html {
root /usr/home/softwares/html;
}
# 请求不带任何参数,重定向至 https://XXXX.com/home
location = / {
return 301 https://XXXX.com/home;}
location / {
add_header 'Access-Control-Allow-Origin' 'http://localhost:8080';
add_header 'Access-Control-Allow-Methods' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Headers' 'access-control-allow-origin, authority, content-type, version-info, X-Requested-With, Authorization, h5token, token, admintoken,authen';if ($request_method = 'OPTIONS') {
return 204;
}
proxy_connect_timeout 6000;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
client_max_body_size 10m;
#limit_reqzone=allips burst=5 nodelay;
proxy_pass http://back;
proxy_redirect http:// https://;
#root /usr/home/softwares/html/home;
#index index.html;}
location /home {
root /usr/home/softwares/html;
index index.html;}
location /manage {
root /usr/home/softwares/html;
index index.html;
}location /much {
root /usr/home/softwares/html;
index index.html;
}location /cloud {
root /usr/home/softwares/html;
index index.html;
}location /bc/KNAxhMmH3y.txt {
alias /usr/home/softwares/html/bc/KNAxhMmH3y.txt;
}
location ~^.+\.txt$ {
root /usr/home/softwares/html;
}
location /wechat/WXPAY_verify_1600698318.txt {
alias /usr/home/softwares/html/wechat/WXPAY_verify_1600698318.txt;
}location /static/images/favicon.ico {
alias /usr/home/softwares/html/static/images/favicon.ico;}
location /images/ {
alias /usr/home/softwares/html/static/images/;
}
location /css/ {
alias /usr/home/softwares/html/static/css/;
}
location /js/ {alias /usr/home/softwares/html/static/js/;
}#gaode map
# 自定义地图服务代理
location /_AMapService/v4/map/styles {
set $args "$args&jscode=高德Key";
proxy_pass https://webapi.amap.com/v4/map/styles;
}
# 海外地图服务代理
location /_AMapService/v3/vectormap {
set $args "$args&jscode=高德Key";
proxy_pass https://fmap01.amap.com/v3/vectormap;
}
# Web服务API 代理
location /_AMapService/ {
set $args "$args&jscode=高德Key";
proxy_pass https://restapi.amap.com/;
}}
}
其中,9091、9092为网关服务, 与这台NGINX部署在同一台机器。
1.1 网关
网关使用spring-cloud-gateway , 与传统网关不同的是,spring-cloud-gateway 结合了 spring-security , 对所有的非白名单入网流量进行安全验证,鉴权的原理稍后介绍。 先看 核心的maven 依赖。 服务注册和服务发现使用了nacos。 注意各版本依赖。我使用的 nacos版本是 2.1.2, 故对应的客户端版本是2.1.2。 版本不妆容将会导致各种各样的问题。以下是版本的对照关系。
依赖 | 版本 |
nacos | 2.2.6.RELEASE |
nacos-client | 2.1.2 |
spring-boot | 2.3.2.RELEASE |
spring-cloud | Hoxton.SR9 |
spring-cloud-alibaba-dependencies | 2.2.6.RELEASE |
spring-cloud-starter-alibaba-nacos-config | 2.2.6.RELEASE |
spring-cloud-starter-loadbalancer | 2.2.6.RELEASE |
以下是 pom.xml
1.1.1 pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.3.2.RELEASE</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>com.dian.coding</groupId><artifactId>dian-gateway</artifactId><version>0.0.1-SNAPSHOT</version><name>dian-gateway</name><description>dian-gateway</description><properties><java.version>8</java.version><redis.client.version>2.9.0</redis.client.version><spring.data.redis.version>2.0.8.RELEASE</spring.data.redis.version><reactor.version>3.1.8.RELEASE</reactor.version><feign.version>9.5.1</feign.version><slf4j.version>1.7.25</slf4j.version><!-- nacos --><nacos.version>2.2.6.RELEASE</nacos.version><spring.cloud.version>Hoxton.SR9</spring.cloud.version><spring.boot.version>2.3.2.RELEASE</spring.boot.version><nacos.client.version>2.1.2</nacos.client.version><spring.cloud.alibaba.version>2.2.9.RELEASE</spring.cloud.alibaba.version></properties><dependencies><dependency><groupId>com.dian.coding.sdk</groupId><artifactId>dian-aes-sdk-bi</artifactId><version>1.1</version></dependency><dependency><groupId>com.alibaba.nacos</groupId><artifactId>nacos-client</artifactId><version>2.1.2</version></dependency><dependency><groupId>org.json.web</groupId><artifactId>dian-jwt-encrypt</artifactId><version>1.0</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-gateway</artifactId></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-security</artifactId></dependency><!-- fegin --><!--fegin组件--><!-- Feign Client for loadBalancing --><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId><exclusions><exclusion><artifactId>spring-cloud-starter</artifactId><groupId>org.springframework.cloud</groupId></exclusion></exclusions></dependency><!-- end of spring-cloud-gateway --><dependency><groupId>io.github.openfeign</groupId><artifactId>feign-okhttp</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency><!-- start of nacos --><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId><exclusions><exclusion><groupId>com.alibaba.nacos</groupId><artifactId>nacos-client</artifactId></exclusion><exclusion><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-netflix-ribbon</artifactId></exclusion></exclusions></dependency><!-- 保障配置文件能够动态更新 --><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency><!-- end of nacos --><dependency><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId><version>1.7.30</version> </dependency><dependency><groupId>org.slf4j</groupId><artifactId>slf4j-log4j12</artifactId><version>1.7.30</version> </dependency><dependency><groupId>javax.xml.bind</groupId><artifactId>jaxb-api</artifactId><version>2.3.0</version></dependency><dependency><groupId>com.sun.xml.bind</groupId><artifactId>jaxb-impl</artifactId><version>2.3.0</version></dependency><dependency><groupId>com.sun.xml.bind</groupId><artifactId>jaxb-core</artifactId><version>2.3.0</version></dependency><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-alibaba-dependencies</artifactId><type>pom</type><scope>import</scope><version>${spring.cloud.alibaba.version}</version></dependency></dependencies><dependencyManagement><dependencies><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-alibaba-dependencies</artifactId><version>${nacos.version}</version><type>pom</type><scope>import</scope></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-dependencies</artifactId><version>${spring.cloud.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><build><finalName>dian-gateway</finalName><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
1.1.2 bootstrap.yaml
说明:
(1) 使用 network-interface: eth0 而不显示指定IP,可以减少云主机IP变动未同步修改配置文件IP的风险。
(2) springCloud中需要禁用ribbon。
cloud:
loadbalancer:
ribbon:
enabled: false
nacos:ip: XX spring:application:name: dian-gatewayprofile:active: prodcloud:loadbalancer:ribbon:enabled: false inetutils:preferred-networks: ${nacos.ip}bootstrap:enabled: truelog-enable: truenacos:config:refresh:enabled: trueext-config[0]:data-id: ${spring.application.name}-${spring.profile.active}.yamlgroup: ${spring.profile.active}refresh: trueserver-addr: ${nacos.ip}:8848file-extension: yamlcontextPath: /nacosnamespace: 3ca2f55d-060b-4eee-ade7-cfb91976b6bdgroup: ${spring.profile.active}username: nacos-clientpassword: nacos-client@#1031 refresh-enabled: trueauto-refresh: trueusername: clientpassword: nacos-client@#1031group: ${spring.profile.active}data-id: ${spring.application.name}-${spring.profile.active}.yamlnamespace: 3ca2f55d-060b-4eee-ade7-cfb91976b6bddiscovery:metadata:preserved.heart.beat.interval: 3 #心跳间隔。时间单位:秒。心跳间隔preserved.heart.beat.timeout: 6 #心跳暂停。时间单位:秒。 即服务端6秒收不到客户端心跳,会将该客户端注册的实例设为不健康:preserved.ip.delete.timeout: 9 #Ip删除超时。时间单位:秒。即服务端9秒收不到客户端心跳,会将该客户端注册的实例删除:enable: trueusername: nacosUserNamepassword: nacosePasswordserver-addr: ${nacos.ip}:8848contextPath: /nacosservice: ${spring.application.name}namespace: 4ca2f00d-060b-4eee-ade7-cf781976b690group: ${spring.profile.active}secure: falsenetwork-interface: eth0accessKey: accessKeysecretKey: accessSecurtymanagement:endpoints:web:exposure:include: '*'
1.1.4 application.yaml
说明: 使用es256 JWT实现加解密。
通过 spring.cloud.gateway.routes 配置微服务的路由。 参考如下:
whiteList: /v1/user/token # 白名单 server:port: 9091es256:privateKeyPath: /home/ES256/es256-private-key.pempublicKey: |-----BEGIN PUBLIC KEY-----MFkwEwYHKoZIzj0CAQYIKoZIzj78wPuq4RGhqa9woLE/0uiOaqpLVJEGEJ7DybT70afBTSp0y5qAKx+Lr4KMX1Mlb+/FkdsGcvYqoWw==-----END PUBLIC KEY-----spring:application:name: dian-gatewaycloud:loadbalancer:ribbon:enabled: falsegateway:discovery:locator:enabled: trueroutes:- id: dian-merchandiseuri: lb://dian-merchandisepredicates:- Path=/api/mer/**filters:- StripPrefix=0- id: dian-orderuri: lb://dian-orderpredicates:- Path=/api/order/*省略其他。。。
1.1.5 网关过滤器
网关过滤器处理请求,实现转发、限流、鉴权等功能。
SecurityWefluxConfig.java 使用 spring-security来实现RBAC控制。
package com.smart.rest.config.security.webflux;import com.alibaba.fastjson.JSONObject; import com.dian.coding.sdk.AesUtil; import io.netty.util.CharsetUtil; import lombok.extern.log4j.Log4j2; import org.json.web.jwt.JwtUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.LockedException; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono;/*** spring-security 核心配置模块*/@Log4j2 @Configuration @EnableWebFluxSecurity public class SecurityWefluxConfig {@Autowiredprivate MySecurityAuthenManager mySecurityAuthenManager;@Value("${whiteList}")private String whiteList;@Autowiredprivate AesUtil aesUtil ;@Autowiredprivate JwtUtils jwtUtils;@AutowiredSecurityContextRepository securityContextRepository;@Autowiredprivate AuthenSuccessHandler authenSuccessHandler;@Autowiredprivate AuthenFailHandler authenFailHander;@Autowiredprivate LogoutHandler logoutHandler;@Autowiredprivate UnauthenEntrypoint unauthenEntrypoint;@Beanpublic SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {SecurityWebFilterChain chain =http.cors().and().csrf().disable()//http//.csrf().disable()//.cors().disable().authenticationManager(mySecurityAuthenManager).securityContextRepository(securityContextRepository).addFilterBefore(new GatewayFilter(aesUtil, jwtUtils),SecurityWebFiltersOrder.CORS).authorizeExchange().pathMatchers(" "/api/admin_spring_security_login","/api/open/account/get", "/security_superadmin/gen/barcode").permitAll().pathMatchers("/adm/changepwd").hasAnyAuthority("MUCH_ADMIN","SUPER_ADMIN","ADMIN_EDIT","STAFF_EDIT").pathMatchers("/adm/superadmin/**").hasAnyAuthority("SUPER_ADMIN").pathMatchers("/security_much/**").hasAnyAuthority("MUCH_ADMIN","SUPER_ADMIN").pathMatchers("/staff/**").hasAnyAuthority("STAFF_EDIT").and().exceptionHandling().authenticationEntryPoint(unauthenEntrypoint) //未登录访问资源时的处理类,若无此处理类,前端页面会弹出登录.accessDeniedHandler(new ServerAccessDeniedHandler() {@Overridepublic Mono<Void> handle(ServerWebExchange serverWebExchange, AccessDeniedException e) {JSONObject res = new JSONObject();res.fluentPut("resCode", "403").fluentPut("resMsg", "敏感资源拒绝访问");ServerHttpResponse response = serverWebExchange.getResponse();response.getHeaders().set(HttpHeaders.CONTENT_TYPE, "application/json; charset=UTF-8");String result = JSONObject.toJSONString(res);DataBuffer buffer = response.bufferFactory().wrap(result.getBytes(CharsetUtil.UTF_8));return response.writeWith(Mono.just(buffer));}}).and().build();return chain;}}
spring-security 仅一张表 admin_roles ( 后台帐号与角色关联表)实现了后台帐号与角色、权限的关系。 表参考如下:
1.1.6 网关鉴权的逻辑
那么,网关鉴权的逻辑是怎样的?
首先,云端后台帐号登录成功时,返回的JSON结果字段中指定roles字段保存角色名称集合,使用平台es256公钥 JWT 加密返回的JSON结果记为token ,H5将此返回 token在每次HTTP请求时均带上头部token, 网关读取token 再用平台私钥对token JWT 解密。
先看网关过滤器逻辑:
值得注意的是nacos负载均衡转发HTTP协议默认的是HTTPS,需要转成HTP协议。
网关主要是对HTTP使用JWT解析头部token,获取roles 集合,再将解析对象转JSON后转发HTTP头部给下游微服务。 不需要下游微服务再执行JWT解析头部token。一是:下游微服务是没有平台私钥,降低私钥泄密的风险;二是:由网关层JWT解析加密token并完成鉴权,不需要微服务二次解析token,提高了系统性能。由于RSA公私钥加解密是有性能损耗的。以下是网关的鉴权逻辑:
package com.smart.rest.config.security.webflux;import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.coding.dian.sdk.StackTool; import com.coding.dian.sdk.constants.ResEnum; import com.coding.dian.sdk.constants.TokenEnum; import com.dian.coding.sdk.AesUtil; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.json.web.jwt.JwtUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.Ordered; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono;import java.net.URI; import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.Map;@Slf4jpublic class GatewayFilter implements WebFilter, Ordered {private AesUtil aesUtil;private JwtUtils jwtUtils;public GatewayFilter(AesUtil aesUtil, JwtUtils jwtUtils) {this.aesUtil = aesUtil;this.jwtUtils = jwtUtils;}private static String CODE_OP_FAIL = "1";private static String CODE_TOKEN_EXPIRED = "4031021";public final static String KEY_MEMBER_LOGIN = "key_member_login";public final static String KEY_ADMIN_LOGIN_SUCCESS = "key_admin_login_success";@Overridepublic Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {ServerHttpResponse response = exchange.getResponse();ServerHttpRequest request = exchange.getRequest();try {String path = request.getPath().toString();log.info("---->进入过滤器GatewayFilter path:{}, body {}", path, request.getBody());String admintoken = exchange.getRequest().getHeaders().getFirst(TokenEnum.admintoken.name());String h5token = exchange.getRequest().getHeaders().getFirst(TokenEnum.h5token.name());String minitoken = exchange.getRequest().getHeaders().getFirst(TokenEnum.Authorization.name());String awstoken = exchange.getRequest().getHeaders().getFirst(TokenEnum.authen.name()); // SQS Eventlog.info("--->admintoken {} , h5token {} , minitoken {} ", admintoken, h5token, minitoken);if (StringUtils.hasText(admintoken)) {Claims claims = jwtUtils.verifyToken(admintoken); // JWT不需要转码log.info("--->claims:{}", claims);String user = (String) claims.get(KEY_ADMIN_LOGIN_SUCCESS);log.info("--->后端头部解析user {}", user);JSONObject userObj = JSONObject.parseObject(user);JSONArray roles = userObj.getJSONArray("roles");Collection<GrantedAuthority> authorities = new ArrayList<>();for (int i = 0; i < roles.size(); i++) {String role = roles.getString(i);GrantedAuthority authority = new SimpleGrantedAuthority(role);authorities.add(authority);}String adminToken = URLEncoder.encode(userObj.toJSONString(), "UTF-8"); //UTF-8转码log.info("---->后端头部转发 token {} ", adminToken);String username = userObj.getString("username");Authentication authentication = new UsernamePasswordAuthenticationToken(username, admintoken, authorities);// 每次请求都更新SecurityContextHolder.getContext()SecurityContextHolder.getContext().setAuthentication(authentication);log.info("---->管理员ROLES状态保持成功<-----");JSONObject haderMap = new JSONObject().fluentPut("key", TokenEnum.admintoken.name()).fluentPut("value", adminToken);return forward(exchange, chain, haderMap);} else {return chain.filter(exchange);}} catch (Exception e) {if (e instanceof ExpiredJwtException) {log.info("--->Token过期了<-----");return this.writeErrorMessage(ResEnum.token_expired.getCode(), response, HttpStatus.INTERNAL_SERVER_ERROR, "Token已过期");}log.error(StackTool.error(e, 100));return this.writeErrorMessage(CODE_OP_FAIL, response, HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());}}protected Mono<Void> writeErrorMessage(String code, ServerHttpResponse response, HttpStatus status, String msg) {JSONObject base = new JSONObject();base.put(ResEnum.resCode.name(), code);base.put(ResEnum.resMsg.name(), msg);String body = JSONObject.toJSONString(base);DataBuffer dataBuffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));response.getHeaders().set("content-Type", "application/json;charset=UTF-8");return response.writeWith(Mono.just(dataBuffer));}@Overridepublic int getOrder() {return 10103;}private Mono<Void> forward(ServerWebExchange exchange, WebFilterChain chain, JSONObject headerMap) {ServerHttpRequest request = exchange.getRequest();String forwardedUri = request.getURI().toString();URI originalUri = request.getURI();if (forwardedUri.startsWith("https")) {try {log.info("<--执行HTTPS转HTTP逻辑-->");ServerHttpRequest.Builder mutate = request.mutate();URI mutatedUri = new URI("http",originalUri.getUserInfo(),originalUri.getHost(),originalUri.getPort(),originalUri.getPath(),originalUri.getQuery(),originalUri.getFragment());if (headerMap != null) {log.info(">---执行https头部转发<---");String[] values = new String[]{headerMap.getString("value")};mutate.uri(mutatedUri).header(headerMap.getString("key"), values);} else {mutate.uri(mutatedUri);}ServerHttpRequest build = mutate.build();return chain.filter(exchange.mutate().request(build).build());} catch (Exception e) {log.error(StackTool.error(e, 100));throw new IllegalStateException(e.getMessage(), e);}} else {log.info("--->协议非HTTPS<-----");if (headerMap != null) {log.info("--->执行HTTP转发头部信息<-------");String[] values = new String[]{headerMap.getString("value")};ServerHttpRequest httpRequest = exchange.getRequest().mutate().header(headerMap.getString("key"), values[0]).build();return chain.filter(exchange.mutate().request(httpRequest).build());} else {return chain.filter(exchange);}}}}
以下是spring-security 由帐号的角色集合roles实现权限校验的逻辑
package com.smart.rest.config.security.webflux;import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.coding.dian.sdk.StackTool; import com.coding.dian.sdk.constants.Constants; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import lombok.Data; import lombok.extern.log4j.Log4j2; import okhttp3.Response; import okhttp3.ResponseBody; import org.json.web.jwt.JwtUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.loadbalancer.LoadBalancerClient; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component;import java.net.URLDecoder; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map;@Data @Log4j2 @Component public class MySecurityAuthenProvider implements AuthenticationProvider {private String userName;private String passWord;private List<String> roles;@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {try {String access_token = (String) authentication.getPrincipal();log.info("----> access_token:{}", access_token);JSONObject json = JSONObject.parseObject(URLDecoder.decode(access_token,"UTF-8"));JSONArray roles = json.getJSONArray("roles");String userName = json.getString("username");log.info("---> userName : {}", userName);List<GrantedAuthority> authorities = new ArrayList<>();for(int i=0 ;i < roles.size(); i++) {String role = (String)roles.get(i);authorities.add(new SimpleGrantedAuthority(role));}log.info("---> roles : {}", roles);return new MyAuthentication(userName, null, authorities, null);}catch(Exception e) {log.error(StackTool.error(e,100));if (e instanceof ExpiredJwtException) {throw new RuntimeException("token过期");}throw new RuntimeException(e.getMessage());}}@Overridepublic boolean supports(Class<?> authentication) {return true;}}
1.2 微服务
微服务使用spring-cloud, 注册中心和配置中心均使用nacos, 与网关使用相同的spring版本。这 儿不再累述。 网关和微服务均通过nacos注册中心注册,服务发现使用spring-cloud-starter-alibaba-nacos-discovery,可实现微服务高可用。
2 微信第三方平台小程序的设计
第三方平台小程序简单来说,就是你开发一个完整的小程序,可以提供给有需求的第三方使用。
2.1 准备工作
见微信官方文档。平台必须搭建好第三方平台小程序,主要是平台处理微信推送的消息与事件接收的逻辑。可以参考微信官方开放文档第三方平台准备工作部分介绍。
微信官方API调用比较有规律,现以小程序API授权回调处理为例简单讲解一下处理逻辑。
企业法人授权小程序API,平台方会接收到微信的推送。
API: /wechat/event/wechat/event/grant/callback
/*** 第三方平台授权后的回调, 返回授权码,拿授权码获取授权信息* @param authorization_code* @param auth_code* @return*/ @RequestMapping(value= {"/grant/callback"},method=RequestMethod.GET) public JSONObject grantCallback(@RequestParam(value="auth_code", required = true) String auth_code) {log.info("--->授权回调 auth_code:{}", auth_code );//授权码获取授权信息JSONObject jsonResult = wechatPlatformService.getApiQueryAuth(auth_code);log.info("小程序API授权回调 json:{}", jsonResult);JSONObject authorization_info = jsonResult.getJSONObject("authorization_info");String authorizer_appid= authorization_info.getString("authorizer_appid");String authorizer_access_token = authorization_info.getString("authorizer_access_token");String authorizer_refresh_token = authorization_info.getString("authorizer_refresh_token");int expires_in = authorization_info.getIntValue("expires_in");PlatformGrant grant = new PlatformGrant();grant.setComponent_appid(wXBizMsgCrypt.getAppId());grant.setAuthorizer_access_token(authorizer_access_token);grant.setAuthorizer_appid(authorizer_appid);grant.setAuthorizer_refresh_token(authorizer_refresh_token);grant.setExpires_in(expires_in);wechatPlatformService.savePlatformGrant(grant);log.info("-->授权信息已保存<----");return jsonResult;}
将用户的授权信息持久化到云端。 以下是wechatPlatformService.getApiQueryAuth(auth_code)的业务逻辑。
public JSONObject getApiQueryAuth(String authorization_code) {String component_access_token = getComponentAccessToken(wXBizMsgCrypt.getAppId());HttpHeaders headers = new HttpHeaders();MediaType type = MediaType.parseMediaType("application/json; charset=utf-8");headers.setContentType(type);headers.add("Accept", MediaType.APPLICATION_XML.toString());JSONObject reqBody = new JSONObject();reqBody.put("component_appid", wXBizMsgCrypt.getAppId());reqBody.put("authorization_code", authorization_code);HttpEntity<JSONObject> formEntity = new HttpEntity<JSONObject>(reqBody, headers);restTemplate.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8));String url = "https://api.weixin.qq.com/cgi-bin/component/api_query_auth?component_access_token=" + component_access_token;JSONObject result = restTemplate.postForObject(url, formEntity, JSONObject.class);log.info(">>>授权码获取授权信息 返回: result=" + result);return result;}
2.2 代企业创建小程序
微信代企业创建小程序这个功能的确很棒,大大降低了企业(或个体户)使用小程序的门槛。
2.2.1 代企业创建小程序的好处
(1) 不用每年交300元小程序审核费用;如果企业或个体户自己去创建小程序,流程手续复杂不说,还要每年交300元小程序(或公众号)审核费用。
(2) 微信提供了代企业创建小程序的接口,企业或个体户的法人可以填写小程序信息直接申请。
业务过程是:企业或个体户通过平台小程序提供的接口填写小程序申请资料(法人微信号、小程序名称等信息)提交到微信官方,微信官方审核通过后会给商家法人推送一条微信链接,商家法人打开微信链接后进行身份认证即可开通,同时微信官方将审核结果推送给平台小程序,平台小程序收到推送后获取商家小程序的ID,即authorizer_appid进行持久化。
这部分的设计如下:
首先是表设计,见如下:
CREATE TABLE public.t_platform_grant (
id int8 NOT NULL,
component_appid varchar(20) NOT NULL,
authorizer_appid varchar(20) NOT NULL,
authorizer_access_token varchar(255) NULL,
expires_in int4 NULL,
authorizer_refresh_token varchar(255) NULL,
update_time varchar(19) NOT NULL,
company_name varchar(62) NULL,
contact_people varchar(12) NULL,
contact_tel varchar(12) NULL,
much_mini_name varchar(64) NULL,
qrcode_url varchar(200) NULL,
CONSTRAINT component_authorizer_appid_key UNIQUE (component_appid, authorizer_appid),
CONSTRAINT platform_grant_pkey PRIMARY KEY (id)
);
其中:authorizer_appid:是商家(想使用第三方平台小程序的企业或个体户)申请的小程序授权ID,这个值必须在第三方平台持久化。
authorizer_access_token和 authorizer_refresh_token分别是票据信息和刷新票据信息,刷新票据authorizer_refresh_token是在authorizer_access_token过期后用来申请新的票据信息。
这几个商家小程序参数平台方后面要反复使用到。
3 小程序
3.1 首页设计
首页进入商家小程序,首先是门店列表及距离信息。 由小程序与门店的关联可以获取与小程序关联的所有门店。 通过高德地图来获取定位。
3.2 商品页
进入门店,选购商品,商品信息从缓存中获取。
商品详情页:
对于有规格的商品,需要从商品的SKU表中加载:
以下是商品详情页的数据结构: (最多支持3个维度, SKU维度遵循笛卡尔集,事实上电商应用中很少超过3个维度的), 前端要实现上图商品详情页的效果,需要算法。
{"keys":[{"key_id":"10494","values":[{"value_id":10497,"value":"1支装"},{"value_id":10496,"value":"2支装"},{"value_id":10495,"value":"3支装"}],"key":"包装"},{"key_id":"10498","values":[{"value_id":10500,"value":"顺德陈村"},{"value_id":10499,"value":"顺德大良"}],"key":"产地 "},{"key_id":"10501","values":[{"value_id":10502,"value":"标准"},{"value_id":10503,"value":"高档"}],"key":"档次"}],"values":[{"standard_price":1.00,"skus":[{"value_id":10497,"value":"1支装"},{"value_id":10500,"value":"顺德陈村"},{"value_id":10502,"value":"标准"}],"underline_price":1.10,"value_ids":[10497,10500,10502],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10497,"value":"1支装"},{"value_id":10500,"value":"顺德陈村"},{"value_id":10503,"value":"高档"}],"underline_price":1.10,"value_ids":[10497,10500,10503],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10497,"value":"1支装"},{"value_id":10499,"value":"顺德大良"},{"value_id":10502,"value":"标准"}],"underline_price":1.10,"value_ids":[10497,10499,10502],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10497,"value":"1支装"},{"value_id":10499,"value":"顺德大良"},{"value_id":10503,"value":"高档"}],"underline_price":1.10,"value_ids":[10497,10499,10503],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10496,"value":"2支装"},{"value_id":10500,"value":"顺德陈村"},{"value_id":10502,"value":"标准"}],"underline_price":1.10,"value_ids":[10496,10500,10502],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10496,"value":"2支装"},{"value_id":10500,"value":"顺德陈村"},{"value_id":10503,"value":"高档"}],"underline_price":1.10,"value_ids":[10496,10500,10503],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10496,"value":"2支装"},{"value_id":10499,"value":"顺德大良"},{"value_id":10502,"value":"标准"}],"underline_price":1.10,"value_ids":[10496,10499,10502],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10496,"value":"2支装"},{"value_id":10499,"value":"顺德大良"},{"value_id":10503,"value":"高档"}],"underline_price":1.10,"value_ids":[10496,10499,10503],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10495,"value":"3支装"},{"value_id":10500,"value":"顺德陈村"},{"value_id":10502,"value":"标准"}],"underline_price":1.10,"value_ids":[10495,10500,10502],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10495,"value":"3支装"},{"value_id":10500,"value":"顺德陈村"},{"value_id":10503,"value":"高档"}],"underline_price":1.10,"value_ids":[10495,10500,10503],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10495,"value":"3支装"},{"value_id":10499,"value":"顺德大良"},{"value_id":10502,"value":"标准"}],"underline_price":1.10,"value_ids":[10495,10499,10502],"stock":"30","sale_price":1.00},{"standard_price":1.00,"skus":[{"value_id":10495,"value":"3支装"},{"value_id":10499,"value":"顺德大良"},{"value_id":10503,"value":"高档"}],"underline_price":1.10,"value_ids":[10495,10499,10503],"stock":"30","sale_price":1.00}],"resCode":"0","resMsg":"success"}
3.3 购物车
购物车的实现基本原则是 map 结构,为了达到最佳性能,购物车操作不需要与服务端交互,数据在小程序本地端存储。
提交订单前,服务器会检测微信用户有无登录。如果没有登录,将弹出登录提示。而不是一
进入小程序就要求用户微信登录。
授权后,可以获取微信用户的openid。 服务器将openid放在JWT token 中加密存储。调用微信统一下单后需要用到这个token。
以下是提交统一下单的购物车数据结构
{"delivery_fix":"0.01","total_price":"1.01","delivery":"2","shopid":8804,"openid":"ojM8n5G69FSxi348Cu1aBefp_3c04","tableNo":1,"nickname":"微信用户","mers":[{"image":"https://s3.cn-northwest-1.amazonaws.com.cn/coding-2020/merchandise/10091/20230109_bb80a544-ccc3-41f7-a53d-7edf4774ed9c.png","thumb_path":"https://s3.cn-northwest-1.amazonaws.com.cn/coding-2020/merchandise/10091/90/90/20230109_bb80a544-ccc3-41f7-a53d-7edf4774ed9c.png","unit":"只","sortid":10087,"price":"1.00","keys":[{"key_id":10494,"key":"包装"},{"key_id":10498,"key":"产地 "},{"key_id":10501,"key":"档次"}],"name":"夏季玫瑰","shopid":8804,"sort":"情人节主题","id":10091,"selectedCount":1,"haslabel":"yes","label":"1支装 顺德大良 标准 ","stock":"30","underline_price":1.1,"sale_price":1,"standard_price":1,"symbol":"10497,10499,10502","key":"10091-10497,10499,10502","label_price":"1-1","selected":true,"itemPrice":"1.00","counts":[],"count":1}],"address":{"name":"张大帅","mobile":"13311111111","province":"天津市","city":"天津市","postcode":"572000","nationalcode":"120103","detail":"天津市天津市河西区梅江街道126号","district":"河西区","location":"117.215914,39.062842"}}
小程序为兼容到店消费(如:点餐中的堂食)和物流配送 (部分商家是愿意在半径范围内自行配送的)。 在用户提交订单前对消费模式作进一步确认。
选择物流配送后,接下来需要用户填写配送信息。 小程序可以直接调用微信官方的收件地址功能 ,这一点节省了开发者大量的开发时间,为微信点赞。
接下来就是支付环节,将再下一个章节进行讲解。
支付(这里的支付动作包括调用微信统一下单接口、平台保存订单及配送信息)。
支付之前调用高德地图计算用户的收件地址距离门店的距离。距离超出门店的最大配送半径将弹出提示。
3.4 订单设计
设计过电商应用的同行应该遇到过这个问题,就是用户在下单后没有支付,此时,系统中存在大量未支付的订单数据,不排队有恶意提交的订单数据。良好的订单设计要及时清理系统多余的订单数据而不影响系统的性能,同时要实现订单倒计时,如:下单后15分钟不支付将作废,系统要将废弃的订单删除。
用户点击“订单”菜单,可以看到订单列表的Tab页。
订单详情界面:
对于未及时支付的订单有一个倒计时器。
订单设计见以下时序图(简单画了下)。具体的实现是:用户提交订单至第三方小程序平台,平台保存订单后,延迟14分50秒发送异步订单删除消息给AWS,AWS lambda 函数触发后向平台方发送HTTP请求删除订单, 平台方判断订单有无支付,如果没有支付就直接删除。
异步删除订单消息使用AWS SQS(按量计费)也可以用阿里云rocketMQ来代替。
详细设计提交至GITHUB:GitHub - alanjiang/mini-wechat-doc: 微信第三方平台小程序平台设计
后期将源码开源。
微信第三方平台小程序平台设计相关推荐
- 【毕业设计之微信小程序系列】基于APP的微信点餐小程序的设计与实现
基于APP的微信点餐小程序的设计与实现 摘 要 本文介绍了一种基于APP的微信点餐小程序的设计与实现方法.该系统利用微信公众号作为用户入口,用户可以通过微信扫码进入点餐系统,选择菜品.下单.支付等操作 ...
- 基于微信小程序的免费小说阅读平台小程序的设计与实现 毕业设计 毕设源码(1)小程序功能
小程序功能截图
- 【小程序页面设计模板】小程序设计模板平台分享
小程序页面设计模板是小程序制作的好工具,特别是对于没有太多代码基础的企业.下面我分享一个小程序设计模板平台,超60个行业的小程序页面设计模板免费使用,页面内容丰富样式多样的小程序设计模板. 小程序设计 ...
- 微信公众平台、微信公众平台.小程序、微信.开放平台三者关系及unionid
以下内容,仅限于根据自己开发以及阅读微信文档总结,错误之处敬请指出,共同进步! 一.微信公众平台.微信公众平台.小程序.微信.开放平台登录地址 项目 微信公众平台 微信公众平台.小程序 微信.开放平台 ...
- 相比于传统的 App,基于小程序所设计导出的 App 有什么优点
微信小程序可以一键转换成APP了?! 是的,你没有听错. 近几年,微信小程序因简洁.轻便,流量池大且运营成本低,备受企业青睐.然而,当企业在微信生态里通过小程序获得流量红利后,难免会想将已有用户引流到 ...
- 心理服务平台微信小程序的设计与实现-计算机毕业设计
随着计算机技术的发展,带来社会各行业的进步,信息化逐渐运用到人们的生活中.传统模式的青少年心理健康管理满足不了现代人的生活追求,服务质量.服务速度,之前的很多网站由于功能.或者框架设计等原因,无法完美 ...
- 肯德基微信小程序连接服务器异常,微信小程序平台常见问题及解决方案
原标题:微信小程序平台常见问题及解决方案 现在越来越多的人开始制作自己的小程序,但由于缺少经验,以及对微信小程序平台缺乏了解,会犯一些低级错误,导致自己制作小程序的时候频频受阻.这里我列举了一些常见问 ...
- 小程序开发运营必看:微信小程序平台运营规范
一.原则及相关说明 微信最核心的价值,就是连接--提供一对一.一对多和多对多的连接方式,从而实现人与人.人与智能终端.人与社交化娱乐.人与硬件设备的连接,同时连接服务.资讯.商业. 微信团队一 ...
- 开发运营必看,跳出雷区必须知道的微信小程序平台运营规范
一.原则及相关说明 微信最核心的价值,就是连接--提供一对一.一对多和多对多的连接方式,从而实现人与人.人与智能终端.人与社交化娱乐.人与硬件设备的连接,同时连接服务.资讯.商业. 微信团队一 ...
最新文章
- 中国太阳能电池行业运营需求与十四五展望规划报告2022版
- rp-provide-from-last
- Mac之当前目录打开终端
- JUnit:使用Java 8和Lambda表达式测试异常
- cv2.error: opencv(4.4.0)_【从零学习OpenCV 4】轮廓面积与长度
- java 4位数,java 找出4位数的所有吸血鬼数字
- 【转】项目代码风格要求
- iOS原生的AVFoundation扫描二维码/条形码
- 彩虹云商城 最新彩虹代刷V6.9.0免授权纯净完整版
- 化学专业有必要学python吗-cnBeta.COM - 中文业界资讯站
- 业务逻辑漏洞挖掘-某网站绕过下载付费机制进行下载文件
- 接口技术实验:七段码显示
- ioncube 加密项目本地搭建
- CF756div3 vp
- Win32汇编语言基础(1)
- camer驱动模块加载分析
- codecombat极客战记森林1-20关
- 微信小程序:ibeacon实现室内定位签到模型
- html+js画一颗心形,javascript绘制漂亮的心型线效果完整实例
- 电脑录音软件哪个好 怎么用电脑录音
热门文章
- “三门问题”背后的概率论原理解析
- 数学建模学习笔记(2):TOPSIS方法(优劣解距离法)和熵权法修正
- @Configuration和@Component
- 论文翻译——Feature Pyramid Networks for Object Detection
- 【渝粤题库】国家开放大学2021春2226物业管理实务(2)题目
- 请用matlab语言计算一下多自由度无阻尼自由振动的固有频率
- 123. 买卖股票的最佳时机 III ( 三维dp )
- 立Flag 学习Ng - 高可用配置
- JavaScript--倒计时
- 牛客3007E-立方数-欧拉线性筛+素数分解+二分