Sfoglia il codice sorgente

开发长链路缩短的功能

lutong 1 settimana fa
parent
commit
e9ee167bf3

+ 104 - 0
alien-store/docs/short-link.md

@@ -0,0 +1,104 @@
+# 短链接(Short Link)说明
+
+`alien-store` 模块提供「长链 → 短链 → 302 跳转」能力,供前端生成微信分享等场景的短 URL。映射存储在 **Redis**,键前缀为 `short:link:`。
+
+## 接口
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| `POST` | `/store/short-link/shorten` | 请求体:`{"longUrl":"<完整长链接>"}`,成功返回 `shortUrl`、`code` |
+| `GET` | `/store/short-link/s/{code}` | 返回 `R<ShortLinkResolveResponse>`,其中 `data.longUrl` 为原始长链;失败为业务失败文案 |
+| `GET` | `/store/short-link/s/{code}/open` | **302** 跳转至长链(浏览器直开/书签);不存在或过期 **404** |
+
+网关需将上述路径路由到 `alien-store`。JSON 接口的返回体为项目统一的 `R<T>`。
+
+**说明**:分享出去的短链地址一般为 `…/store/short-link/s/{code}`。若在微信内由 **小程序 / H5 请求**解析,请调 **`GET /s/{code}`** 取 `longUrl` 后再 `navigateTo` 或 `location.replace`。若需 **系统浏览器打开即跳**,可把分享链指向 **`/s/{code}/open`**,或继续用原短链前缀但由落地页先请求 `/s/{code}` 再跳转。
+
+## 配置项
+
+| 配置键 | 说明 | 默认值 |
+|--------|------|--------|
+| `short-link.public-base-url` | 对外短链前缀(**无尾斜杠**),须含协议与端口(若有)。含网关 `context-path` 时写全 | 空(未配置时生成接口会报错) |
+| `short-link.allowed-hosts` | 允许被缩短的**长链**域名白名单,逗号分隔(小写 host)。**空**表示不按白名单限制(仍会拦截内网,除非开发开关打开) | 空 |
+| `short-link.https-only` | 长链是否**仅允许 https** | `true` |
+| `short-link.allow-local-network-target` | 是否允许长链指向 localhost / 内网 IP。**仅本地开发** | `false` |
+| `short-link.ttl-days` | Redis 中映射保留天数 | `30` |
+
+生成的完整短链形如(解析用第一段 URL 即可;若需 302 直达可加 `/open`):
+
+`{short-link.public-base-url}/store/short-link/s/{code}`
+
+## 各环境推荐配置
+
+### 开发环境(dev)
+
+适合本机或内网联调;长链可能是 `http://127.0.0.1`、`192.168.x`。
+
+```yaml
+short-link:
+  public-base-url: http://127.0.0.1:8080
+  https-only: false
+  allow-local-network-target: true
+  allowed-hosts:
+  ttl-days: 30
+```
+
+手机同 WiFi 联调时,将 `public-base-url` 改为 **`http://<本机局域网IP>:端口`**。微信内打开需可访问该地址;公网联调可用 ngrok 等穿透,将前缀改为穿透提供的 `https://...`。
+
+### 测试环境(test / sit)
+
+```yaml
+short-link:
+  public-base-url: https://test-api.example.com
+  https-only: true
+  allow-local-network-target: false
+  allowed-hosts: m.test.example.com,www.test.example.com
+  ttl-days: 30
+```
+
+若测试环境暂无 HTTPS,可临时 `https-only: false`,**不要**在生产打开 `allow-local-network-target`。
+
+### 预生产环境(uat / pre)
+
+与生产策略一致,仅换预发域名。
+
+```yaml
+short-link:
+  public-base-url: https://uat-api.example.com
+  https-only: true
+  allow-local-network-target: false
+  allowed-hosts: m.uat.example.com,www.uat.example.com
+  ttl-days: 30
+```
+
+### 生产环境(prod)
+
+```yaml
+short-link:
+  public-base-url: https://api.example.com
+  https-only: true
+  allow-local-network-target: false
+  allowed-hosts: m.example.com,www.example.com
+  ttl-days: 30
+```
+
+生产环境**必须** `allow-local-network-target: false`,并**强烈建议**配置 `allowed-hosts`,缩小开放重定向面。
+
+## Nacos 建议
+
+- 各环境差异项(`public-base-url`、`allowed-hosts`、`https-only`、`allow-local-network-target`)放在对应环境的 data-id(如 `alien-store-dev.yml`、`alien-store-prod.yml`)。
+- 共性项(如 `ttl-days`)可放在 `common.yml`。
+
+## 相关代码
+
+| 类型 | 路径 |
+|------|------|
+| Controller | `shop.alien.store.controller.ShortLinkController` |
+| Service | `shop.alien.store.service.impl.ShortLinkServiceImpl` |
+| 工具 | `shop.alien.store.util.Base62Util`、`shop.alien.store.util.ShortLinkUrlValidator` |
+| DTO | `shop.alien.store.dto.ShortLinkShortenRequest`、`ShortLinkShortenResponse` |
+
+## 安全说明
+
+- 长链会做基本校验:协议、长度、(可选)域名白名单、内网地址拦截(可通过 `allow-local-network-target` 在**仅开发**放开)。
+- 跳转接口为公开访问,请勿在生产开启 `allow-local-network-target`。

+ 68 - 0
alien-store/src/main/java/shop/alien/store/controller/ShortLinkController.java

@@ -0,0 +1,68 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.store.dto.ShortLinkResolveResponse;
+import shop.alien.store.dto.ShortLinkShortenRequest;
+import shop.alien.store.dto.ShortLinkShortenResponse;
+import shop.alien.store.service.ShortLinkService;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 短链接:前端生成短链、解析长链(JSON);可选 open 路径 302。
+ */
+@Api(tags = {"短链接"})
+@Slf4j
+@CrossOrigin
+@RestController
+@RequestMapping("/store/short-link")
+@RequiredArgsConstructor
+public class ShortLinkController {
+
+    private final ShortLinkService shortLinkService;
+
+    @ApiOperation(value = "生成短链", notes = "需在配置 short-link.public-base-url 后返回完整 https 短 URL。可选 short-link.allowed-hosts 限制长链域名。")
+    @PostMapping("/shorten")
+    public R<ShortLinkShortenResponse> shorten(@RequestBody ShortLinkShortenRequest request) {
+        if (request == null || !StringUtils.hasText(request.getLongUrl())) {
+            return R.fail("longUrl 不能为空");
+        }
+        try {
+            ShortLinkShortenResponse res = shortLinkService.shorten(request.getLongUrl());
+            return R.data(res);
+        } catch (IllegalArgumentException ex) {
+            return R.fail(ex.getMessage());
+        } catch (Exception ex) {
+            log.warn("生成短链失败: {}", ex.getMessage());
+            return R.fail("生成短链失败");
+        }
+    }
+
+    @ApiOperation(value = "解析短链", notes = "返回原始 longUrl,由前端自行跳转;无需登录")
+    @GetMapping("/s/{code}")
+    public R<ShortLinkResolveResponse> resolve(@PathVariable String code) {
+        String longUrl = shortLinkService.resolveLongUrl(code);
+        if (!StringUtils.hasText(longUrl)) {
+            return R.fail("链接不存在或已过期");
+        }
+        return R.data(new ShortLinkResolveResponse(longUrl));
+    }
+
+    @ApiOperation(value = "短链浏览器跳转", notes = "302 到原始长链接;无需登录(书签/H5 直开用,小程序一般用上方 JSON 接口)")
+    @GetMapping("/s/{code}/open")
+    public void openRedirect(@PathVariable String code, HttpServletResponse response) throws IOException {
+        String longUrl = shortLinkService.resolveLongUrl(code);
+        if (!StringUtils.hasText(longUrl)) {
+            response.sendError(HttpServletResponse.SC_NOT_FOUND, "链接不存在或已过期");
+            return;
+        }
+        response.sendRedirect(longUrl);
+    }
+}

+ 20 - 0
alien-store/src/main/java/shop/alien/store/dto/ShortLinkResolveResponse.java

@@ -0,0 +1,20 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 解析短链结果(前端自行跳转,如 webview / uni)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@ApiModel(value = "短链解析结果")
+public class ShortLinkResolveResponse {
+
+    @ApiModelProperty(value = "原始长链接")
+    private String longUrl;
+}

+ 16 - 0
alien-store/src/main/java/shop/alien/store/dto/ShortLinkShortenRequest.java

@@ -0,0 +1,16 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 生成短链请求
+ */
+@Data
+@ApiModel(value = "短链生成请求")
+public class ShortLinkShortenRequest {
+
+    @ApiModelProperty(value = "需要缩短的完整长链接(建议 https)", required = true)
+    private String longUrl;
+}

+ 23 - 0
alien-store/src/main/java/shop/alien/store/dto/ShortLinkShortenResponse.java

@@ -0,0 +1,23 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 生成短链响应
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@ApiModel(value = "短链生成结果")
+public class ShortLinkShortenResponse {
+
+    @ApiModelProperty(value = "完整短链接,可直接用于微信分享")
+    private String shortUrl;
+
+    @ApiModelProperty(value = "短码(路径段)")
+    private String code;
+}

+ 19 - 0
alien-store/src/main/java/shop/alien/store/service/ShortLinkService.java

@@ -0,0 +1,19 @@
+package shop.alien.store.service;
+
+import shop.alien.store.dto.ShortLinkShortenResponse;
+
+/**
+ * 短链接:生成与解析重定向目标
+ */
+public interface ShortLinkService {
+
+    /**
+     * 为长链接生成短链(写入 Redis,带过期时间)
+     */
+    ShortLinkShortenResponse shorten(String longUrl);
+
+    /**
+     * 根据短码解析长链接;不存在或过期返回 null
+     */
+    String resolveLongUrl(String code);
+}

+ 105 - 0
alien-store/src/main/java/shop/alien/store/service/impl/ShortLinkServiceImpl.java

@@ -0,0 +1,105 @@
+package shop.alien.store.service.impl;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import shop.alien.store.dto.ShortLinkShortenResponse;
+import shop.alien.store.service.ShortLinkService;
+import shop.alien.store.util.Base62Util;
+import shop.alien.store.util.ShortLinkUrlValidator;
+
+import java.util.Set;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ShortLinkServiceImpl implements ShortLinkService {
+
+    private static final String REDIS_KEY_PREFIX = "short:link:";
+    private static final int MAX_GENERATE_RETRY = 12;
+
+    private final StringRedisTemplate stringRedisTemplate;
+
+    /**
+     * 对外展示的短链前缀(不含末尾斜杠),如 https://m.example.com 或 https://api.example.com/app
+     */
+    @Value("${short-link.public-base-url:}")
+    private String publicBaseUrl;
+
+    /**
+     * 逗号分隔的允许长链域名白名单(小写 host),为空则不校验域名仅拦截内网
+     */
+    @Value("${short-link.allowed-hosts:}")
+    private String allowedHostsCsv;
+
+    /**
+     * 仅允许 https 长链,默认 true
+     */
+    @Value("${short-link.https-only:true}")
+    private boolean httpsOnly;
+
+    /**
+     * 是否允许长链指向 localhost/内网(仅本地调试;生产必须为 false)
+     */
+    @Value("${short-link.allow-local-network-target:false}")
+    private boolean allowLocalNetworkTarget;
+
+    /**
+     * Redis 过期天数,默认 30
+     */
+    @Value("${short-link.ttl-days:30}")
+    private int ttlDays;
+
+    @Override
+    public ShortLinkShortenResponse shorten(String longUrl) {
+        Set<String> allowed = ShortLinkUrlValidator.parseAllowedHostsCsv(allowedHostsCsv);
+        String normalized = ShortLinkUrlValidator.validateAndNormalize(longUrl, allowed, httpsOnly, allowLocalNetworkTarget);
+        long ttlSeconds = Math.max(1L, (long) ttlDays * 24L * 3600L);
+
+        String prefix = normalizePublicBaseUrl(publicBaseUrl);
+        if (!StringUtils.hasText(prefix)) {
+            throw new IllegalArgumentException("请配置 short-link.public-base-url(对外完整域名前缀,无尾斜杠,例如 https://m.example.com)");
+        }
+
+        for (int i = 0; i < MAX_GENERATE_RETRY; i++) {
+            long id = ThreadLocalRandom.current().nextLong(1L, Long.MAX_VALUE);
+            String code = Base62Util.encodePositiveLong(id);
+            String key = REDIS_KEY_PREFIX + code;
+            Boolean created = stringRedisTemplate.opsForValue().setIfAbsent(key, normalized, ttlSeconds, TimeUnit.SECONDS);
+            if (Boolean.TRUE.equals(created)) {
+                String path = "/store/short-link/s/" + code;
+                String shortUrl = prefix + path;
+                return new ShortLinkShortenResponse(shortUrl, code);
+            }
+        }
+        throw new IllegalStateException("短链生成失败,请稍后重试");
+    }
+
+    @Override
+    public String resolveLongUrl(String code) {
+        if (!StringUtils.hasText(code)) {
+            return null;
+        }
+        String trimmed = code.trim();
+        if (trimmed.length() > 64 || !trimmed.matches("[0-9A-Za-z]+")) {
+            return null;
+        }
+        return stringRedisTemplate.opsForValue().get(REDIS_KEY_PREFIX + trimmed);
+    }
+
+    private static String normalizePublicBaseUrl(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return "";
+        }
+        String s = raw.trim();
+        while (s.endsWith("/")) {
+            s = s.substring(0, s.length() - 1);
+        }
+        return s;
+    }
+}

+ 53 - 0
alien-store/src/main/java/shop/alien/store/util/Base62Util.java

@@ -0,0 +1,53 @@
+package shop.alien.store.util;
+
+/**
+ * 正整数的 Base62 编解码(0-9A-Za-z),用于短链码生成。
+ */
+public final class Base62Util {
+
+    private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
+    private static final int BASE = ALPHABET.length();
+
+    private Base62Util() {
+    }
+
+    /**
+     * 将正整数编码为 Base62 字符串(不含符号位,{@code value == 0} 时结果为单个首字符)。
+     */
+    public static String encodePositiveLong(long value) {
+        if (value < 0) {
+            throw new IllegalArgumentException("value must be non-negative");
+        }
+        if (value == 0) {
+            return String.valueOf(ALPHABET.charAt(0));
+        }
+        StringBuilder sb = new StringBuilder();
+        long v = value;
+        while (v > 0) {
+            sb.append(ALPHABET.charAt((int) (v % BASE)));
+            v /= BASE;
+        }
+        return sb.reverse().toString();
+    }
+
+    /**
+     * Base62 解码为 long;非法字符时抛出 {@link IllegalArgumentException}。
+     */
+    public static long decodeToPositiveLong(String code) {
+        if (code == null || code.isEmpty()) {
+            throw new IllegalArgumentException("code is empty");
+        }
+        long result = 0;
+        for (int i = 0; i < code.length(); i++) {
+            int digit = ALPHABET.indexOf(code.charAt(i));
+            if (digit < 0) {
+                throw new IllegalArgumentException("invalid base62 char: " + code.charAt(i));
+            }
+            result = result * BASE + digit;
+            if (result < 0) {
+                throw new IllegalArgumentException("overflow");
+            }
+        }
+        return result;
+    }
+}

+ 133 - 0
alien-store/src/main/java/shop/alien/store/util/ShortLinkUrlValidator.java

@@ -0,0 +1,133 @@
+package shop.alien.store.util;
+
+import java.net.IDN;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 短链目标地址校验:HTTPS(可配置是否允许 HTTP)、长度、内网/本地地址、可选域名白名单。
+ */
+public final class ShortLinkUrlValidator {
+
+    private static final int MAX_URL_LENGTH = 2048;
+
+    private ShortLinkUrlValidator() {
+    }
+
+    /**
+     * @param longUrl                   原始 URL
+     * @param allowedHosts              允许的 host(小写),如 {@code www.example.com};为空表示不限制 host(仍会拦截内网地址,除非 {@code allowLocalNetworkTarget})
+     * @param httpsOnly                 true 时仅允许 https
+     * @param allowLocalNetworkTarget   true 时不拦截 localhost/内网 IP(仅用于本地开发,禁止线上开启)
+     * @return 规范化后的 URL(trim)
+     */
+    public static String validateAndNormalize(String longUrl, Set<String> allowedHosts, boolean httpsOnly,
+                                              boolean allowLocalNetworkTarget) {
+        if (longUrl == null || longUrl.trim().isEmpty()) {
+            throw new IllegalArgumentException("长链接不能为空");
+        }
+        String trimmed = longUrl.trim();
+        if (trimmed.length() > MAX_URL_LENGTH) {
+            throw new IllegalArgumentException("长链接过长");
+        }
+        URI uri;
+        try {
+            uri = new URI(trimmed);
+        } catch (URISyntaxException e) {
+            throw new IllegalArgumentException("长链接格式无效");
+        }
+        String scheme = uri.getScheme();
+        if (scheme == null) {
+            throw new IllegalArgumentException("长链接需包含协议(https)");
+        }
+        String s = scheme.toLowerCase(Locale.ROOT);
+        if (httpsOnly) {
+            if (!"https".equals(s)) {
+                throw new IllegalArgumentException("仅支持 https 长链接");
+            }
+        } else {
+            if (!"https".equals(s) && !"http".equals(s)) {
+                throw new IllegalArgumentException("仅支持 http / https 长链接");
+            }
+        }
+        String host = uri.getHost();
+        if (host == null || host.isEmpty()) {
+            throw new IllegalArgumentException("长链接缺少主机名");
+        }
+        String hostLower = IDN.toUnicode(host).toLowerCase(Locale.ROOT);
+        if (!allowLocalNetworkTarget && isBlockedHost(hostLower)) {
+            throw new IllegalArgumentException("不允许使用该主机地址");
+        }
+        if (allowedHosts != null && !allowedHosts.isEmpty()) {
+            if (!hostMatchesAllowlist(hostLower, allowedHosts)) {
+                throw new IllegalArgumentException("长链接域名不在允许列表内");
+            }
+        }
+        return trimmed;
+    }
+
+    public static Set<String> parseAllowedHostsCsv(String csv) {
+        if (csv == null || csv.trim().isEmpty()) {
+            return Collections.emptySet();
+        }
+        return Arrays.stream(csv.split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .map(s -> s.toLowerCase(Locale.ROOT))
+                .collect(Collectors.toCollection(LinkedHashSet::new));
+    }
+
+    private static boolean hostMatchesAllowlist(String host, Set<String> allowedHosts) {
+        for (String allowed : allowedHosts) {
+            if (allowed == null || allowed.isEmpty()) {
+                continue;
+            }
+            if (host.equals(allowed) || host.endsWith("." + allowed)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean isBlockedHost(String hostLower) {
+        if ("localhost".equals(hostLower)) {
+            return true;
+        }
+        if ("0.0.0.0".equals(hostLower) || "::1".equals(hostLower)) {
+            return true;
+        }
+        // IPv4 简单规则:127.*、10.*、192.168.*、169.254.*、172.16-31.*
+        if (hostLower.startsWith("127.")) {
+            return true;
+        }
+        if (hostLower.startsWith("10.")) {
+            return true;
+        }
+        if (hostLower.startsWith("192.168.")) {
+            return true;
+        }
+        if (hostLower.startsWith("169.254.")) {
+            return true;
+        }
+        if (hostLower.startsWith("172.")) {
+            String[] parts = hostLower.split("\\.");
+            if (parts.length >= 2) {
+                try {
+                    int second = Integer.parseInt(parts[1]);
+                    if (second >= 16 && second <= 31) {
+                        return true;
+                    }
+                } catch (NumberFormatException ignored) {
+                    // ignore
+                }
+            }
+        }
+        return false;
+    }
+}