|
|
@@ -49,9 +49,17 @@ public class InviteService {
|
|
|
@Value("${invite.base-url:https://app.zhijiayun.com}")
|
|
|
private String inviteBaseUrl;
|
|
|
|
|
|
- /** 桌面客户端下载基础URL,为空时自动从 inviteBaseUrl 推断域名 */
|
|
|
- @Value("${invite.desktop-download-base-url:}")
|
|
|
- private String desktopDownloadBaseUrl;
|
|
|
+ /** 客户端安装包本地存储目录 */
|
|
|
+ @Value("${invite.download-base-path:./downloads}")
|
|
|
+ private String downloadBasePath;
|
|
|
+
|
|
|
+ /** Windows 安装包文件名 */
|
|
|
+ @Value("${invite.windows-filename:zhijiayun-x64.exe}")
|
|
|
+ private String windowsFilename;
|
|
|
+
|
|
|
+ /** macOS 安装包文件名 */
|
|
|
+ @Value("${invite.mac-filename:zhijiayun-arm64.dmg}")
|
|
|
+ private String macFilename;
|
|
|
|
|
|
/** 补填邀请码有效窗口(天),默认30天 */
|
|
|
@Value("${invite.bind-window-days:30}")
|
|
|
@@ -157,8 +165,8 @@ public class InviteService {
|
|
|
// 根据渠道生成提示文案
|
|
|
String instructionText = generateInstructionText(code, channel, config);
|
|
|
|
|
|
- // 落地页下载按钮指向 /api/invite/{code},访问时会再次记录点击并自动重定向到对应安装包
|
|
|
- String inviteDownloadUrl = inviteBaseUrl + "/invite/" + code;
|
|
|
+ // 落地页下载按钮指向 /api/invite/{code},服务端按设备流式返回对应安装包
|
|
|
+ String inviteDownloadUrl = inviteBaseUrl + "/api/invite/" + code;
|
|
|
|
|
|
// Windows/app 渠道使用邀请链接作为下载入口(自动识别设备并重定向),其他渠道保持配置中的下载地址
|
|
|
String appDownloadUrl = ("windows".equals(channel) || "app".equals(channel))
|
|
|
@@ -246,56 +254,72 @@ public class InviteService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 解析桌面客户端下载完整URL(公开,无需登录)
|
|
|
- * 根据 User-Agent 判断系统架构,返回对应下载包地址
|
|
|
+ * 根据 User-Agent 解析本次应下载的安装包文件
|
|
|
+ * Windows x64 -> {downloadBasePath}/智价云-x64.exe
|
|
|
+ * macOS ARM64 -> {downloadBasePath}/智价云-arm64.dmg
|
|
|
+ * 其他默认返回 Windows 安装包
|
|
|
*/
|
|
|
- public String resolveDownloadUrl(String code, String userAgent) {
|
|
|
- // 校验邀请码存在性(可选:不存在也允许下载,这里只做简单校验并记录点击)
|
|
|
- InviteCode inviteCode = inviteCodeMapper.selectOne(
|
|
|
- new LambdaQueryWrapper<InviteCode>()
|
|
|
- .eq(InviteCode::getCode, code));
|
|
|
- if (inviteCode != null) {
|
|
|
- inviteCode.setClickCount(inviteCode.getClickCount() + 1);
|
|
|
- inviteCodeMapper.updateById(inviteCode);
|
|
|
- log.debug("下载链接 {} 被访问,累计点击 {} 次", code, inviteCode.getClickCount());
|
|
|
- }
|
|
|
- return resolveDesktopDownloadUrl(userAgent);
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 根据 User-Agent 判断系统并返回桌面客户端下载地址
|
|
|
- * Windows x64 -> /desktop-updates/智价云-x64.exe
|
|
|
- * macOS ARM64 -> /desktop-updates/智价云-arm64.dmg
|
|
|
- * 其他默认返回 Windows x64 包
|
|
|
- */
|
|
|
- private String resolveDesktopDownloadUrl(String userAgent) {
|
|
|
- String baseUrl = getDesktopDownloadBaseUrl();
|
|
|
- String path = "/desktop-updates/智价云-x64.exe";
|
|
|
+ public java.io.File resolveDownloadFile(String userAgent) {
|
|
|
+ String filename = windowsFilename;
|
|
|
if (userAgent != null) {
|
|
|
String ua = userAgent.toLowerCase();
|
|
|
boolean isMac = ua.contains("macintosh") || ua.contains("mac os");
|
|
|
boolean isArm = ua.contains("arm64") || ua.contains("aarch64");
|
|
|
if (isMac && isArm) {
|
|
|
- path = "/desktop-updates/智价云-arm64.dmg";
|
|
|
+ filename = macFilename;
|
|
|
}
|
|
|
}
|
|
|
- return baseUrl + path;
|
|
|
+ return new java.io.File(downloadBasePath, filename);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 获取桌面客户端下载基础URL
|
|
|
- * 优先使用配置 invite.desktop-download-base-url,未配置时从 inviteBaseUrl 推断域名根地址
|
|
|
+ * 构造支持中文文件名的 Content-Disposition 响应头
|
|
|
+ * 使用 RFC 5987 的 filename*=UTF-8'' 编码,兼容主流浏览器
|
|
|
*/
|
|
|
- private String getDesktopDownloadBaseUrl() {
|
|
|
- if (desktopDownloadBaseUrl != null && !desktopDownloadBaseUrl.isBlank()) {
|
|
|
- return desktopDownloadBaseUrl.replaceAll("/$", "");
|
|
|
+ private String buildContentDisposition(String filename) {
|
|
|
+ String asciiOnly = filename.replaceAll("[^\\x00-\\x7F]", "_");
|
|
|
+ String encoded = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8)
|
|
|
+ .replaceAll("\\+", "%20");
|
|
|
+ return "attachment; filename=\"" + asciiOnly + "\"; filename*=UTF-8''" + encoded;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 流式下载邀请链接对应的安装包(公开,无需登录)
|
|
|
+ * 自动计入点击数,并根据 User-Agent 选择安装包文件写入响应流
|
|
|
+ */
|
|
|
+ public void downloadInviteFile(String code, String userAgent, jakarta.servlet.http.HttpServletResponse response) {
|
|
|
+ // 校验邀请码存在性并记录点击
|
|
|
+ InviteCode inviteCode = inviteCodeMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<InviteCode>()
|
|
|
+ .eq(InviteCode::getCode, code));
|
|
|
+ if (inviteCode == null) {
|
|
|
+ throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
|
|
|
}
|
|
|
- try {
|
|
|
- java.net.URL url = new java.net.URL(inviteBaseUrl);
|
|
|
- return url.getProtocol() + "://" + url.getAuthority();
|
|
|
- } catch (Exception e) {
|
|
|
- log.warn("无法从 inviteBaseUrl 推断下载域名: {}", inviteBaseUrl, e);
|
|
|
- return inviteBaseUrl.replaceAll("/$", "");
|
|
|
+ inviteCode.setClickCount(inviteCode.getClickCount() + 1);
|
|
|
+ inviteCodeMapper.updateById(inviteCode);
|
|
|
+ log.debug("下载链接 {} 被访问,累计点击 {} 次", code, inviteCode.getClickCount());
|
|
|
+
|
|
|
+ java.io.File file = resolveDownloadFile(userAgent);
|
|
|
+ if (!file.exists() || !file.isFile()) {
|
|
|
+ log.error("安装包文件不存在: {}", file.getAbsolutePath());
|
|
|
+ throw new BusinessException(ErrorCode.BUSINESS_ERROR, "安装包文件不存在,请联系管理员");
|
|
|
+ }
|
|
|
+
|
|
|
+ response.setContentType("application/octet-stream");
|
|
|
+ response.setHeader("Content-Disposition", buildContentDisposition(file.getName()));
|
|
|
+ response.setContentLengthLong(file.length());
|
|
|
+
|
|
|
+ try (java.io.InputStream in = new java.io.FileInputStream(file);
|
|
|
+ java.io.OutputStream out = response.getOutputStream()) {
|
|
|
+ byte[] buffer = new byte[8192];
|
|
|
+ int len;
|
|
|
+ while ((len = in.read(buffer)) != -1) {
|
|
|
+ out.write(buffer, 0, len);
|
|
|
+ }
|
|
|
+ out.flush();
|
|
|
+ } catch (java.io.IOException e) {
|
|
|
+ log.error("流式下载安装包失败: {}", file.getAbsolutePath(), e);
|
|
|
+ throw new BusinessException(ErrorCode.BUSINESS_ERROR, "下载失败,请重试");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -370,7 +394,7 @@ public class InviteService {
|
|
|
|
|
|
return InviteLinkResolveResponse.builder()
|
|
|
.inviteCode(code)
|
|
|
- .inviteLink(inviteBaseUrl + "/invite/" + code)
|
|
|
+ .inviteLink(inviteBaseUrl + "/api/invite/" + code)
|
|
|
.inviterNickname(inviterNickname)
|
|
|
.inviterAvatar(inviterAvatar)
|
|
|
.appName(config.getAppName())
|
|
|
@@ -853,7 +877,7 @@ public class InviteService {
|
|
|
InviteConfig config = inviteConfigService.getActiveConfig();
|
|
|
User inviter = userMapper.selectById(code.getUserId());
|
|
|
String inviterNickname = getDisplayName(inviter);
|
|
|
- String inviteLink = inviteBaseUrl + "/invite/" + code.getCode();
|
|
|
+ String inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
|
|
|
|
|
|
// 生成分享文案(邀请人 + 产品价值 + 操作指引)
|
|
|
// 分享标题:让接收方一眼知道是谁邀请的
|