Эх сурвалжийг харах

feat(store): 添加微信JS-SDK签名功能支持H5分享

- 创建WxJsSdkConfigVo数据传输对象定义签名参数结构
- 实现WxJsSdkController提供获取微信JS-SDK配置的REST接口
- 添加WxJsSdkProperties配置类管理微信公众号AppID和AppSecret
- 定义WxJsSdkService接口规范JS-SDK签名服务方法
- 实现WxJsSdkServiceImpl提供完整的签名逻辑包括token缓存和SHA1加密
- 集成OkHttp客户端调用微信API获取access_token和jsapi_ticket
- 使用Redis缓存优化token和ticket的有效期管理
- 实现随机字符串生成和URL哈希片段处理功能
fcw 4 өдөр өмнө
parent
commit
202a16b13e

+ 26 - 0
alien-store/src/main/java/shop/alien/store/config/WxJsSdkProperties.java

@@ -0,0 +1,26 @@
+package shop.alien.store.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
+import org.springframework.stereotype.Component;
+
+/**
+ * 微信公众号 JS-SDK 配置(H5 分享、扫一扫等)
+ */
+@Data
+@Component
+@RefreshScope
+@ConfigurationProperties(prefix = "wx")
+public class WxJsSdkProperties {
+
+    /**
+     * 公众号 AppID
+     */
+    private String appId;
+
+    /**
+     * 公众号 AppSecret
+     */
+    private String appSecret;
+}

+ 37 - 0
alien-store/src/main/java/shop/alien/store/controller/WxJsSdkController.java

@@ -0,0 +1,37 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.store.dto.WxJsSdkConfigVo;
+import shop.alien.store.service.WxJsSdkService;
+
+/**
+ * 微信公众号 JS-SDK(H5 分享等)签名接口
+ */
+@Slf4j
+@Api(tags = {"微信公众号-JS-SDK"})
+@CrossOrigin
+@RestController
+@RequestMapping("/wx")
+@RequiredArgsConstructor
+public class WxJsSdkController {
+
+    private final WxJsSdkService wxJsSdkService;
+
+    @ApiOperation("获取微信 JS-SDK 签名参数(前端 wx.config 使用)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "url", value = "当前 H5 页面完整 URL(不含 # 及后面部分,需与前端 location.href 处理后一致)",
+                    dataType = "String", paramType = "query", required = true)
+    })
+    @GetMapping("/getWxConfig")
+    public R<WxJsSdkConfigVo> getWxConfig(@RequestParam String url) {
+        log.info("WxJsSdkController.getWxConfig url={}", url);
+        return R.data(wxJsSdkService.buildJsSdkConfig(url));
+    }
+}

+ 29 - 0
alien-store/src/main/java/shop/alien/store/dto/WxJsSdkConfigVo.java

@@ -0,0 +1,29 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 微信 JS-SDK 前端初始化参数
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@ApiModel(value = "微信JS-SDK签名参数")
+public class WxJsSdkConfigVo {
+
+    @ApiModelProperty(value = "公众号 AppID")
+    private String appId;
+
+    @ApiModelProperty(value = "时间戳(秒)")
+    private String timestamp;
+
+    @ApiModelProperty(value = "随机字符串")
+    private String nonceStr;
+
+    @ApiModelProperty(value = "签名")
+    private String signature;
+}

+ 16 - 0
alien-store/src/main/java/shop/alien/store/service/WxJsSdkService.java

@@ -0,0 +1,16 @@
+package shop.alien.store.service;
+
+import shop.alien.store.dto.WxJsSdkConfigVo;
+
+/**
+ * 微信公众号 JS-SDK 签名服务
+ */
+public interface WxJsSdkService {
+
+    /**
+     * 根据当前 H5 页面 URL 生成 JS-SDK 配置(不含 # 及其后片段)
+     *
+     * @param url 前端当前页完整 URL,需与 wx.config 所用 url 一致
+     */
+    WxJsSdkConfigVo buildJsSdkConfig(String url);
+}

+ 150 - 0
alien-store/src/main/java/shop/alien/store/service/impl/WxJsSdkServiceImpl.java

@@ -0,0 +1,150 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import shop.alien.store.config.BaseRedisService;
+import shop.alien.store.config.WxJsSdkProperties;
+import shop.alien.store.dto.WxJsSdkConfigVo;
+import shop.alien.store.service.WxJsSdkService;
+
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 微信公众号 JS-SDK 签名实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WxJsSdkServiceImpl implements WxJsSdkService {
+
+    private static final String ACCESS_TOKEN_CACHE_KEY = "wx:jsapi:access_token";
+    private static final String JSAPI_TICKET_CACHE_KEY = "wx:jsapi:jsapi_ticket";
+    private static final long CACHE_SECONDS = 7000L;
+
+    private static final String ACCESS_TOKEN_URL =
+            "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
+    private static final String JSAPI_TICKET_URL =
+            "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=%s&type=jsapi";
+
+    private static final SecureRandom RANDOM = new SecureRandom();
+    private static final char[] NONCE_CHARS =
+            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toCharArray();
+
+    private final WxJsSdkProperties wxJsSdkProperties;
+    private final BaseRedisService redisService;
+
+    private final OkHttpClient httpClient = new OkHttpClient.Builder()
+            .connectTimeout(30, TimeUnit.SECONDS)
+            .readTimeout(30, TimeUnit.SECONDS)
+            .writeTimeout(30, TimeUnit.SECONDS)
+            .build();
+
+    @Override
+    public WxJsSdkConfigVo buildJsSdkConfig(String url) {
+        if (!StringUtils.hasText(url)) {
+            throw new IllegalArgumentException("url 不能为空");
+        }
+        String pageUrl = stripHash(url.trim());
+
+        String ticket = getJsapiTicket();
+        String nonceStr = randomNonceStr(15);
+        String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
+
+        String signStr = "jsapi_ticket=" + ticket
+                + "&noncestr=" + nonceStr
+                + "&timestamp=" + timestamp
+                + "&url=" + pageUrl;
+        String signature = DigestUtils.sha1Hex(signStr);
+
+        return new WxJsSdkConfigVo(
+                wxJsSdkProperties.getAppId(),
+                timestamp,
+                nonceStr,
+                signature
+        );
+    }
+
+    private String getAccessToken() {
+        String cached = redisService.getString(ACCESS_TOKEN_CACHE_KEY);
+        if (StringUtils.hasText(cached)) {
+            return cached;
+        }
+
+        String appId = wxJsSdkProperties.getAppId();
+        String appSecret = wxJsSdkProperties.getAppSecret();
+        if (!StringUtils.hasText(appId) || !StringUtils.hasText(appSecret)) {
+            throw new IllegalStateException("未配置 wx.appId / wx.appSecret");
+        }
+
+        String requestUrl = String.format(ACCESS_TOKEN_URL, appId, appSecret);
+        JSONObject body = getWechatJson(requestUrl, "获取 access_token");
+        String accessToken = body.getString("access_token");
+        if (!StringUtils.hasText(accessToken)) {
+            throw new RuntimeException("获取 access_token 失败,返回数据异常: " + body);
+        }
+
+        redisService.setString(ACCESS_TOKEN_CACHE_KEY, accessToken, CACHE_SECONDS);
+        return accessToken;
+    }
+
+    private String getJsapiTicket() {
+        String cached = redisService.getString(JSAPI_TICKET_CACHE_KEY);
+        if (StringUtils.hasText(cached)) {
+            return cached;
+        }
+
+        String accessToken = getAccessToken();
+        String requestUrl = String.format(JSAPI_TICKET_URL, accessToken);
+        JSONObject body = getWechatJson(requestUrl, "获取 jsapi_ticket");
+        String ticket = body.getString("ticket");
+        if (!StringUtils.hasText(ticket)) {
+            throw new RuntimeException("获取 jsapi_ticket 失败,返回数据异常: " + body);
+        }
+
+        redisService.setString(JSAPI_TICKET_CACHE_KEY, ticket, CACHE_SECONDS);
+        return ticket;
+    }
+
+    private JSONObject getWechatJson(String url, String action) {
+        Request request = new Request.Builder().url(url).get().build();
+        try (Response response = httpClient.newCall(request).execute()) {
+            if (!response.isSuccessful()) {
+                throw new RuntimeException(action + "失败,HTTP状态码: " + response.code());
+            }
+            String responseBody = response.body() != null ? response.body().string() : "";
+            JSONObject jsonObject = JSON.parseObject(responseBody);
+            if (jsonObject.containsKey("errcode") && jsonObject.getIntValue("errcode") != 0) {
+                log.error("{}失败,errcode={}, errmsg={}, body={}",
+                        action, jsonObject.getIntValue("errcode"), jsonObject.getString("errmsg"), responseBody);
+                throw new RuntimeException(action + "失败: " + jsonObject.getString("errmsg"));
+            }
+            return jsonObject;
+        } catch (IOException e) {
+            log.error("{}网络请求异常", action, e);
+            throw new RuntimeException(action + "网络请求失败: " + e.getMessage(), e);
+        }
+    }
+
+    private static String stripHash(String url) {
+        int hashIndex = url.indexOf('#');
+        return hashIndex >= 0 ? url.substring(0, hashIndex) : url;
+    }
+
+    private static String randomNonceStr(int length) {
+        StringBuilder sb = new StringBuilder(length);
+        for (int i = 0; i < length; i++) {
+            sb.append(NONCE_CHARS[RANDOM.nextInt(NONCE_CHARS.length)]);
+        }
+        return sb.toString();
+    }
+}