Compare commits

..
2 Commits
Author SHA1 Message Date
mass b6647b07b1 添加license 2026-05-18 09:21:57 +08:00
mass 8b16d70259 添加获取微店订单和修改发货状态 2026-05-17 16:15:48 +08:00
18 changed files with 1331 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
# License 授权保护使用说明
## 一、功能说明
本系统已集成 **MAC 地址绑定授权保护**,防止软件被甲方转移到其他服务器或出售给第三方。
**授权原理:**
1. 程序启动时自动采集服务器所有物理网卡的 MAC 地址作为机器指纹
2. 使用内置密钥对机器指纹进行 HMAC-SHA256 签名校验
3. 与 `license.lic` 文件中的授权码比对,不匹配则拒绝启动
4. 签名密钥硬编码在 jar 包内,甲方无法自行伪造授权文件
---
## 二、部署流程(给甲方部署时)
### 步骤 1:打包项目
在开发机上执行 Maven 打包:
```bash
mvn clean package -DskipTests
```
打包完成后,将 `ruoyi-admin/target/ruoyi-admin.jar` 上传到甲方服务器。
---
### 步骤 2:在甲方服务器上生成 License
**重要:License 必须在目标服务器上生成,不能在开发机上生成后拷贝。**
在甲方服务器上执行以下命令:
```bash
java -cp ruoyi-admin.jar com.ruoyi.common.utils.license.LicenseGenerator
```
**交互示例:**
```
========================================
License 授权文件生成工具
========================================
当前服务器机器指纹(MAC 地址):
08:00:27:A3:B4:C5|0A:00:27:00:00:00
生成的授权码:
a3f5e8d9c2b1a4f6e7d8c9b0a1f2e3d4c5b6a7f8e9d0c1b2a3f4e5d6c7b8a9f0
请输入 license.lic 文件保存路径(如 D:\license\license.lic 或 /opt/license/license.lic):
```
**Windows 示例:**
```
D:\license\license.lic
```
**Linux 示例:**
```
/opt/license/license.lic
```
生成成功后会显示:
```
========================================
License 文件生成成功!
========================================
文件路径:/opt/license/license.lic
下一步操作:
1. 将此路径配置到 application.yml 的 af.license.path 参数中
2. 启动应用程序,系统会自动校验 License
```
---
### 步骤 3:配置 License 路径
编辑 `application.yml`,修改 `af.license.path` 为实际生成的文件路径:
```yaml
af:
license:
path: /opt/license/license.lic # Linux 路径
# path: D:\license\license.lic # Windows 路径
```
**注意:**
- Windows 路径使用反斜杠 `\` 或双反斜杠 `\\`
- Linux 路径使用正斜杠 `/`
---
### 步骤 4:启动程序
```bash
java -jar ruoyi-admin.jar
```
**启动成功示例:**
```
2026-05-17 10:30:15.123 INFO --- License 校验开始,文件路径:/opt/license/license.lic
2026-05-17 10:30:15.234 INFO --- 当前机器指纹:08:00:27:A3:B4:C5|0A:00:27:00:00:00
2026-05-17 10:30:15.345 INFO --- License 校验通过
(♥◠‿◠)ノ゙ 若依启动成功 ლ(´ڡ`ლ)゙
```
**启动失败示例(License 不匹配):**
```
========================================
License 授权校验失败
========================================
错误:License 授权校验失败:当前服务器未获得授权,程序拒绝启动
解决方法:
1. 在目标服务器上运行 License 生成工具:
java -cp ruoyi.jar com.ruoyi.common.utils.license.LicenseGenerator
2. 将生成的 license.lic 文件路径配置到 application.yml 的 af.license.path
========================================
```
---
## 三、常见问题
### Q1:甲方更换了网卡或服务器,程序无法启动怎么办?
**A:** 需要重新生成 License。
1. 在新服务器上运行 License 生成工具
2. 将新生成的 `license.lic` 替换旧文件
3. 重启程序
### Q2:甲方能否自己修改 License 文件?
**A:** 不能。License 文件是用内置密钥签名的,甲方无法伪造。任何手动修改都会导致校验失败。
### Q3:如何修改签名密钥?
**A:** 修改 `LicenseUtils.java` 中的 `HMAC_SECRET` 常量,重新打包即可。
**注意:** 修改密钥后,所有已颁发的 License 都会失效,需要重新生成。
```java
private static final String HMAC_SECRET = "你的新密钥";
```
### Q4:开发环境如何跳过 License 校验?
**方案 1:** 在开发机上生成 License 并配置路径。
**方案 2:** 修改 `application.yml`,将 `af.license.path` 留空或注释掉(会导致启动失败,需要临时注释 `RuoYiApplication` 中的校验代码)。
**推荐方案 1**,保持代码一致性。
### Q5:如何查看当前服务器的机器指纹?
运行 License 生成工具即可看到:
```bash
java -cp ruoyi-admin.jar com.ruoyi.common.utils.license.LicenseGenerator
```
工具会显示当前服务器的 MAC 地址指纹。
---
## 四、安全建议
1. **保护签名密钥:** 不要将 `HMAC_SECRET` 泄露给甲方或第三方
2. **定期更换密钥:** 每次交付新客户时,建议使用不同的密钥版本
3. **加固 jar 包:** 可使用混淆工具(如 ProGuard)进一步保护代码
4. **License 文件权限:** 建议将 `license.lic` 设置为只读权限
---
## 五、技术实现
### 核心类说明
| 类名 | 路径 | 说明 |
|------|------|------|
| `LicenseUtils` | `com.ruoyi.common.utils.license.LicenseUtils` | License 工具类,提供指纹采集、签名、校验功能 |
| `LicenseException` | `com.ruoyi.common.utils.license.LicenseException` | License 异常类 |
| `LicenseGenerator` | `com.ruoyi.common.utils.license.LicenseGenerator` | License 生成工具(独立 main 方法) |
| `RuoYiApplication` | `com.ruoyi.RuoYiApplication` | 启动类,集成 License 校验逻辑 |
### 配置参数
| 参数 | 位置 | 说明 |
|------|------|------|
| `af.license.path` | `application.yml` | License 文件的绝对路径 |
| `HMAC_SECRET` | `LicenseUtils.java` | 签名密钥(硬编码) |
---
## 六、授权流程图
```
┌─────────────────┐
│ 开发者打包项目 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 上传 jar 到甲方 │
│ 服务器 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 运行 License │
│ 生成工具 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 采集 MAC 地址 │
│ 生成 license.lic│
└────────┬────────┘
│
▼
┌─────────────────┐
│ 配置 License │
│ 文件路径 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 启动程序 │
│ 自动校验 License│
└────────┬────────┘
│
┌────┴────┐
│ │
▼ ▼
校验通过 校验失败
正常启动 拒绝启动
```
---
**如有疑问,请联系开发团队。**
Binary file not shown.
@@ -0,0 +1,44 @@
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
/**
* 为远程机器生成 License 授权码(在开发机上运行)
*/
public class GenerateLicenseForRemote
{
private static final String HMAC_SECRET = "D0uY!n@R3ch@rge#S3cr3tK3y$2026";
private static final String HMAC_ALGORITHM = "HmacSHA256";
public static void main(String[] args) throws Exception
{
// 远程机器的 MAC 地址指纹
String remoteFingerprint = "5C:DE:34:E6:40:4C";
System.out.println("远程机器指纹:" + remoteFingerprint);
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(
HMAC_SECRET.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
mac.init(keySpec);
byte[] raw = mac.doFinal(remoteFingerprint.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : raw)
{
sb.append(byteToHex(b));
}
String licenseCode = sb.toString();
System.out.println("生成的授权码:");
System.out.println(licenseCode);
System.out.println();
System.out.println("请将上面的授权码保存到远程机器的 D:\\douyinrecharge\\license\\license.lic 文件中");
}
private static String byteToHex(byte b)
{
final char[] HEX = "0123456789abcdef".toCharArray();
return "" + HEX[(b >> 4) & 0xF] + HEX[b & 0xF];
}
}
+143
View File
@@ -0,0 +1,143 @@
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.File;
import java.net.NetworkInterface;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
/**
* License 独立生成工具,无任何第三方依赖,直接用 JDK 编译运行。
*
* 编译:javac LicenseTool.java
* 运行:java LicenseTool D:\license\license.lic
*
* 注意:HMAC_SECRET 必须与主程序 LicenseUtils.java 中的值完全一致。
*/
public class LicenseTool
{
private static final String HMAC_SECRET = "D0uY!n@R3ch@rge#S3cr3tK3y$2026";
private static final String HMAC_ALGORITHM = "HmacSHA256";
public static void main(String[] args) throws Exception
{
System.out.println("========================================");
System.out.println(" License 授权文件生成工具");
System.out.println("========================================");
System.out.println();
String fingerprint = collectMachineFingerprint();
System.out.println("当前机器指纹(MAC 地址):");
System.out.println(fingerprint);
System.out.println();
String licenseCode = sign(fingerprint);
System.out.println("生成的授权码:");
System.out.println(licenseCode);
System.out.println();
String outputPath;
if (args != null && args.length > 0 && !args[0].trim().isEmpty())
{
outputPath = args[0].trim();
System.out.println("保存路径:" + outputPath);
}
else
{
System.out.print("请输入 license.lic 保存路径(如 D:\\license\\license.lic):");
java.util.Scanner scanner = new java.util.Scanner(System.in);
outputPath = scanner.nextLine().trim();
}
if (outputPath.isEmpty())
{
System.err.println("错误:路径不能为空");
System.exit(1);
}
File outputFile = new File(outputPath);
File parentDir = outputFile.getParentFile();
if (parentDir != null && !parentDir.exists())
{
parentDir.mkdirs();
}
Files.write(Paths.get(outputPath), licenseCode.getBytes(StandardCharsets.UTF_8));
System.out.println();
System.out.println("========================================");
System.out.println(" License 文件生成成功!");
System.out.println("========================================");
System.out.println("文件路径:" + outputFile.getAbsolutePath());
System.out.println();
System.out.println("下一步:将此路径配置到 application.yml 的 af.license.path,然后启动程序。");
System.out.println();
}
private static String collectMachineFingerprint() throws Exception
{
List<String> macList = new ArrayList<>();
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null)
{
throw new RuntimeException("无法枚举网络接口");
}
while (interfaces.hasMoreElements())
{
NetworkInterface ni = interfaces.nextElement();
if (ni.isLoopback() || ni.isVirtual() || !ni.isUp())
{
continue;
}
byte[] mac = ni.getHardwareAddress();
if (mac == null || mac.length == 0)
{
continue;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < mac.length; i++)
{
if (i > 0) sb.append(":");
sb.append(byteToHex(mac[i]).toUpperCase());
}
macList.add(sb.toString());
}
if (macList.isEmpty())
{
throw new RuntimeException("未找到任何物理网卡 MAC 地址");
}
Collections.sort(macList);
String result = "";
for (int i = 0; i < macList.size(); i++)
{
if (i > 0) result += "|";
result += macList.get(i);
}
return result;
}
private static String sign(String fingerprint) throws Exception
{
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(
HMAC_SECRET.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
mac.init(keySpec);
byte[] raw = mac.doFinal(fingerprint.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : raw)
{
sb.append(byteToHex(b));
}
return sb.toString();
}
private static String byteToHex(byte b)
{
final char[] HEX = "0123456789abcdef".toCharArray();
return "" + HEX[(b >> 4) & 0xF] + HEX[b & 0xF];
}
}
@@ -1,5 +1,7 @@
package com.ruoyi;
import com.ruoyi.common.utils.license.LicenseException;
import com.ruoyi.common.utils.license.LicenseUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
@@ -20,9 +22,49 @@ public class RuoYiApplication
{
// System.setProperty("spring.devtools.restart.enabled", "false");
ConfigurableApplicationContext application = SpringApplication.run(RuoYiApplication.class, args);
verifyLicense(application.getEnvironment());
printStartupMessage(application.getEnvironment());
}
/**
* 校验 License 授权,校验失败则退出程序。
*/
private static void verifyLicense(Environment env)
{
String licensePath = env.getProperty("af.license.path");
if (licensePath == null || licensePath.trim().isEmpty())
{
System.err.println("\n========================================");
System.err.println(" License 授权校验失败");
System.err.println("========================================");
System.err.println("错误:未配置 License 文件路径(af.license.path)");
System.err.println("请在 application.yml 中配置 af.license.path 参数");
System.err.println("========================================\n");
System.exit(1);
}
try
{
LicenseUtils.verify(licensePath);
}
catch (LicenseException e)
{
System.err.println("\n========================================");
System.err.println(" License 授权校验失败");
System.err.println("========================================");
System.err.println("错误:" + e.getMessage());
System.err.println();
System.err.println("解决方法:");
System.err.println("1. 在目标服务器上运行 License 生成工具:");
System.err.println(" java -cp ruoyi.jar com.ruoyi.common.utils.license.LicenseGenerator");
System.err.println("2. 将生成的 license.lic 文件路径配置到 application.yml 的 af.license.path");
System.err.println("========================================\n");
System.exit(1);
}
}
/**
* 打印启动成功提示,包含端口与接口文档地址。
*/
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -122,4 +123,37 @@ public class AfOrderController extends BaseController
Long orderId = Convert.toLong(body.get("orderId"));
return toAjax(orderService.retryOrder(orderId, getUsername()));
}
/**
* 从微店拉取订单并同步入库。
*
* @param body 请求体,pageNum 为页码(从 0 开始,不传默认 0)
* @return 同步结果,包含 total(接口返回总数)、inserted(本次新增数)
*/
@PreAuthorize("@ss.hasPermi('af:order:sync')")
@PostMapping("/sync/weidian")
public AjaxResult syncWeidian(@RequestBody(required = false) Map<String, Object> body)
{
int pageNum = body == null ? 0 : Convert.toInt(body.get("pageNum"), 0);
logger.info("syncWeidian 入参:pageNum={}, operator={}", pageNum, getUsername());
Map<String, Object> syncResult = orderService.syncWeidianOrders(pageNum, getUsername());
AjaxResult result = success();
result.putAll(syncResult);
return result;
}
/**
* 通知微店指定订单已发货(虚拟发货)。
*
* @param sourceOrderNo 微店订单号
* @return 发货结果
*/
@PreAuthorize("@ss.hasPermi('af:order:deliver')")
@PostMapping("/deliver/weidian")
public AjaxResult deliverWeidian(@RequestParam String sourceOrderNo)
{
logger.info("deliverWeidian 入参:sourceOrderNo={}, operator={}", sourceOrderNo, getUsername());
orderService.deliverWeidianOrder(sourceOrderNo, getUsername());
return success("发货成功");
}
}
@@ -128,7 +128,15 @@ referer:
# 自动充值业务配置
af:
license:
path: D:\license\license.lic
rechargeUrl: https://www.douyin.com/pay
weidian:
wdToken: e96014f3
cookie: __spider__visitorid=884e5806506186bd; is_login=true; login_type=LOGIN_USER_TYPE_MASTER; login_source=LOGIN_USER_SOURCE_MASTER; uid=1774188581; duid=1774188581; sid=1619718241; smart_login_type=0; hi_dxh=; hold=; cn_merchant=; wdtoken=e96014f3; __spider__sessionid=d7756539bef26b5b; Hm_lvt_f3b91484e26c0d850ada494bff4b469b=1777446699,1778986671; HMACCOUNT=2383A5C40E664A02; login_token=_EwWqqVIQEKuI-Phw6hSEGFcEi9mlSZQq9CGPxfyUsL_A0Fnptw_8BdSEQvLn8r8oOo73sACMqrfJj3n0ichiWNcQwt0cS2Yxh1UgqGCs8bE5zqWBCXV2KZIxKzMApQbuf98L9hOdI1xlZH0pIJ6MT7WXbckhMUaS0juJhzzDpn1YnXxUAB9jy2G7fb2f15UK6_Thhr-eLwj54zYDhTghIixzfpTsmAVvlE_45xetReIESH7SYsHeO1JGiRIEey3yDnlo_-jJ; Hm_lpvt_f3b91484e26c0d850ada494bff4b469b=1778988001; cashier_token=558d8084586ea5a1ecf74c1d372296d5
orderListUrl: https://thor.weidian.com/tradeview/seller.getOrderListForPC/1.0
deliverOrderUrl: https://thor.weidian.com/tradeview/seller.deliverOrder/1.0
pageSize: 20
# 防止XSS攻击
xss:
@@ -6,9 +6,11 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URLConnection;
import java.util.Map;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier;
@@ -203,6 +205,84 @@ public class HttpUtils
return result.toString();
}
/**
* 向指定 URL 发送POST方法的请求,支持自定义请求头。
*
* @param url 发送请求的 URL
* @param param 请求参数,application/x-www-form-urlencoded 格式
* @param headers 自定义请求头,key 为 Header 名称,value 为 Header 值
* @return 响应结果字符串,请求失败时返回空字符串
*/
public static String sendPostWithHeaders(String url, String param, Map<String, String> headers)
{
PrintWriter out = null;
BufferedReader in = null;
StringBuilder result = new StringBuilder();
try
{
log.info("sendPostWithHeaders - {}", url);
URL realUrl = new URL(url);
HttpURLConnection conn = (HttpURLConnection) realUrl.openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
if (headers != null)
{
for (Map.Entry<String, String> entry : headers.entrySet())
{
conn.setRequestProperty(entry.getKey(), entry.getValue());
}
}
conn.setDoOutput(true);
conn.setDoInput(true);
out = new PrintWriter(conn.getOutputStream());
out.print(param);
out.flush();
in = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
String line;
while ((line = in.readLine()) != null)
{
result.append(line);
}
log.info("recv - {}", result);
}
catch (ConnectException e)
{
log.error("调用HttpUtils.sendPostWithHeaders ConnectException, url=" + url, e);
}
catch (SocketTimeoutException e)
{
log.error("调用HttpUtils.sendPostWithHeaders SocketTimeoutException, url=" + url, e);
}
catch (IOException e)
{
log.error("调用HttpUtils.sendPostWithHeaders IOException, url=" + url, e);
}
catch (Exception e)
{
log.error("调用HttpUtils.sendPostWithHeaders Exception, url=" + url, e);
}
finally
{
try
{
if (out != null)
{
out.close();
}
if (in != null)
{
in.close();
}
}
catch (IOException ex)
{
log.error("调用in.close Exception, url=" + url, ex);
}
}
return result.toString();
}
public static String sendSSLPost(String url, String param)
{
return sendSSLPost(url, param, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
@@ -0,0 +1,19 @@
package com.ruoyi.common.utils.license;
/**
* License 授权异常。
*/
public class LicenseException extends Exception
{
private static final long serialVersionUID = 1L;
public LicenseException(String message)
{
super(message);
}
public LicenseException(String message, Throwable cause)
{
super(message, cause);
}
}
@@ -0,0 +1,97 @@
package com.ruoyi.common.utils.license;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Scanner;
/**
* License 生成工具(独立运行)。
*
* <p>使用方法:
* <ol>
* <li>IDEA 开发环境:右键 main 方法 → Run,在 Program arguments 中填写路径,如 D:\license\license.lic</li>
* <li>服务器环境:java -cp ruoyi.jar com.ruoyi.common.utils.license.LicenseGenerator [路径]</li>
* <li>无参数运行:交互式输入路径</li>
* </ol>
*
* <p>注意:此工具必须在目标服务器上运行,不能在开发机上生成后拷贝。
*/
public class LicenseGenerator
{
public static void main(String[] args)
{
System.out.println("========================================");
System.out.println(" License 授权文件生成工具");
System.out.println("========================================");
System.out.println();
try
{
String fingerprint = LicenseUtils.collectMachineFingerprint();
System.out.println("当前服务器机器指纹(MAC 地址):");
System.out.println(fingerprint);
System.out.println();
String licenseCode = LicenseUtils.sign(fingerprint);
System.out.println("生成的授权码:");
System.out.println(licenseCode);
System.out.println();
String outputPath;
if (args != null && args.length > 0 && !args[0].trim().isEmpty())
{
outputPath = args[0].trim();
System.out.println("使用参数指定路径:" + outputPath);
}
else
{
Scanner scanner = new Scanner(System.in);
System.out.print("请输入 license.lic 文件保存路径(如 D:\\license\\license.lic 或 /opt/license/license.lic):");
outputPath = scanner.nextLine().trim();
}
if (outputPath.isEmpty())
{
System.err.println("错误:路径不能为空");
System.exit(1);
}
File outputFile = new File(outputPath);
File parentDir = outputFile.getParentFile();
if (parentDir != null && !parentDir.exists())
{
if (!parentDir.mkdirs())
{
System.err.println("错误:无法创建目录 " + parentDir.getAbsolutePath());
System.exit(1);
}
}
Files.write(Paths.get(outputPath), licenseCode.getBytes(StandardCharsets.UTF_8));
System.out.println();
System.out.println("========================================");
System.out.println(" License 文件生成成功!");
System.out.println("========================================");
System.out.println("文件路径:" + outputFile.getAbsolutePath());
System.out.println();
System.out.println("下一步操作:");
System.out.println("1. 将此路径配置到 application.yml 的 af.license.path 参数中");
System.out.println("2. 启动应用程序,系统会自动校验 License");
System.out.println();
}
catch (LicenseException e)
{
System.err.println("错误:" + e.getMessage());
System.exit(1);
}
catch (Exception e)
{
System.err.println("生成 License 文件失败:" + e.getMessage());
e.printStackTrace();
System.exit(1);
}
}
}
@@ -0,0 +1,185 @@
package com.ruoyi.common.utils.license;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.NetworkInterface;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
/**
* License 授权工具类。
*
* <p>授权原理:
* <ol>
* <li>采集当前服务器所有物理网卡的 MAC 地址,排序后拼接为指纹字符串。</li>
* <li>用内置密钥对指纹做 HMAC-SHA256 签名,得到授权码。</li>
* <li>将授权码写入 license.lic 文件(由开发者在目标机器上生成后交付)。</li>
* <li>程序启动时重新采集 MAC 指纹,重新计算签名,与 license.lic 内容比对。</li>
* </ol>
*
* <p>防篡改:签名密钥硬编码在 jar 内,甲方无法自行伪造 license.lic。
*/
public class LicenseUtils
{
private static final Logger log = LoggerFactory.getLogger(LicenseUtils.class);
/**
* HMAC 签名密钥,打包后不可见,修改此值会使所有已颁发的 License 失效。
* 生产部署前请替换为你自己的随机字符串。
*/
private static final String HMAC_SECRET = "D0uY!n@R3ch@rge#S3cr3tK3y$2026";
private static final String HMAC_ALGORITHM = "HmacSHA256";
/**
* 采集当前机器所有物理网卡的 MAC 地址,排序后拼接为机器指纹。
*
* @return 机器指纹字符串,格式如 "AA:BB:CC:DD:EE:FF|11:22:33:44:55:66"
* @throws LicenseException 无法获取任何 MAC 地址时抛出
*/
public static String collectMachineFingerprint() throws LicenseException
{
List<String> macList = new ArrayList<>();
try
{
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null)
{
throw new LicenseException("无法枚举网络接口");
}
while (interfaces.hasMoreElements())
{
NetworkInterface ni = interfaces.nextElement();
if (ni.isLoopback() || ni.isVirtual() || !ni.isUp())
{
continue;
}
byte[] mac = ni.getHardwareAddress();
if (mac == null || mac.length == 0)
{
continue;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < mac.length; i++)
{
if (i > 0)
{
sb.append(":");
}
sb.append(byteToHex(mac[i]).toUpperCase());
}
macList.add(sb.toString());
}
}
catch (LicenseException e)
{
throw e;
}
catch (Exception e)
{
throw new LicenseException("采集 MAC 地址失败:" + e.getMessage(), e);
}
if (macList.isEmpty())
{
throw new LicenseException("未找到任何物理网卡 MAC 地址,无法生成机器指纹");
}
Collections.sort(macList);
return String.join("|", macList);
}
/**
* 对机器指纹做 HMAC-SHA256 签名,生成授权码。
*
* @param fingerprint 机器指纹
* @return 十六进制授权码字符串
* @throws LicenseException 签名失败时抛出
*/
public static String sign(String fingerprint) throws LicenseException
{
try
{
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(
HMAC_SECRET.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
mac.init(keySpec);
byte[] raw = mac.doFinal(fingerprint.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : raw)
{
sb.append(byteToHex(b));
}
return sb.toString();
}
catch (Exception e)
{
throw new LicenseException("License 签名失败:" + e.getMessage(), e);
}
}
/**
* 将单个字节转为两位小写十六进制字符串,不依赖 String.format。
*/
private static String byteToHex(byte b)
{
final char[] HEX = "0123456789abcdef".toCharArray();
return "" + HEX[(b >> 4) & 0xF] + HEX[b & 0xF];
}
/**
* 校验 License 文件是否与当前机器匹配。
*
* <p>校验逻辑:读取 licenseFilePath 文件内容,与当前机器指纹的签名比对。
*
* @param licenseFilePath license.lic 文件的绝对路径
* @throws LicenseException 文件不存在、内容为空或签名不匹配时抛出
*/
public static void verify(String licenseFilePath) throws LicenseException
{
log.info("License 校验开始,文件路径:{}", licenseFilePath);
if (licenseFilePath == null || licenseFilePath.trim().isEmpty())
{
throw new LicenseException("License 文件路径未配置(af.license.path)");
}
String licenseContent;
try
{
licenseContent = new String(
Files.readAllBytes(Paths.get(licenseFilePath.trim())),
StandardCharsets.UTF_8).trim();
}
catch (Exception e)
{
throw new LicenseException("读取 License 文件失败,路径:" + licenseFilePath + ",原因:" + e.getMessage(), e);
}
if (licenseContent.isEmpty())
{
throw new LicenseException("License 文件内容为空");
}
String fingerprint = collectMachineFingerprint();
log.info("当前机器指纹:{}", fingerprint);
String expected = sign(fingerprint);
if (!expected.equalsIgnoreCase(licenseContent))
{
log.error("License 校验失败,当前机器指纹与授权不匹配");
throw new LicenseException("License 授权校验失败:当前服务器未获得授权,程序拒绝启动");
}
log.info("License 校验通过");
}
}
@@ -0,0 +1,78 @@
package com.ruoyi.system.domain.af;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 微店账号配置属性。
* 对应 application.yml 中 af.weidian.* 配置项。
*/
@Component
@ConfigurationProperties(prefix = "af.weidian")
public class AfWeidianProperties
{
/** 微店 wdToken */
private String wdToken;
/** 微店登录 Cookie */
private String cookie;
/** 微店订单列表接口地址 */
private String orderListUrl;
/** 微店发货接口地址 */
private String deliverOrderUrl;
/** 每次拉取的页大小 */
private int pageSize = 20;
public String getWdToken()
{
return wdToken;
}
public void setWdToken(String wdToken)
{
this.wdToken = wdToken;
}
public String getCookie()
{
return cookie;
}
public void setCookie(String cookie)
{
this.cookie = cookie;
}
public String getOrderListUrl()
{
return orderListUrl;
}
public void setOrderListUrl(String orderListUrl)
{
this.orderListUrl = orderListUrl;
}
public String getDeliverOrderUrl()
{
return deliverOrderUrl;
}
public void setDeliverOrderUrl(String deliverOrderUrl)
{
this.deliverOrderUrl = deliverOrderUrl;
}
public int getPageSize()
{
return pageSize;
}
public void setPageSize(int pageSize)
{
this.pageSize = pageSize;
}
}
@@ -115,4 +115,12 @@ public interface AfOrderMapper
public int updateOrderFailReason(@Param("id") Long id,
@Param("failReason") String failReason,
@Param("updateBy") String updateBy);
/**
* 插入订单,若 source+source_order_no 已存在则忽略(幂等写入)
*
* @param order 订单对象
* @return 影响行数,0 表示已存在被忽略,1 表示新插入
*/
public int insertAfOrderIgnoreDuplicate(AfOrder order);
}
@@ -1,6 +1,7 @@
package com.ruoyi.system.service.af;
import java.util.List;
import java.util.Map;
import com.ruoyi.system.domain.af.AfOrder;
/**
@@ -44,4 +45,23 @@ public interface IAfOrderService
* @return 结果
*/
public int retryOrder(Long orderId, String operator);
/**
* 从微店拉取待充值订单并同步入库。
* 使用 INSERT IGNORE 幂等写入,已存在的订单不会重复插入。
*
* @param pageNum 页码(从 0 开始)
* @param operator 操作人
* @return 同步结果摘要,包含 total(接口返回总数)、inserted(本次新增数)
*/
public Map<String, Object> syncWeidianOrders(int pageNum, String operator);
/**
* 调用微店发货接口,通知微店该订单已完成充值(虚拟发货)。
* 仅对来源为 WEIDIAN 的订单执行,其他来源直接忽略。
*
* @param sourceOrderNo 微店订单号(orderId)
* @param operator 操作人
*/
public void deliverWeidianOrder(String sourceOrderNo, String operator);
}
@@ -1,5 +1,7 @@
package com.ruoyi.system.service.af.impl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
@@ -7,11 +9,14 @@ import org.springframework.stereotype.Component;
import com.ruoyi.system.domain.af.AfDispatchPolicy;
import com.ruoyi.system.service.af.IAfDispatchPolicyService;
import com.ruoyi.system.service.af.IAfDispatchService;
import com.ruoyi.system.service.af.IAfOrderService;
@Component
@EnableScheduling
public class AfDispatchScheduler
{
private static final Logger log = LoggerFactory.getLogger(AfDispatchScheduler.class);
private volatile long lastDispatchTimestamp = 0L;
@Autowired
@@ -20,6 +25,9 @@ public class AfDispatchScheduler
@Autowired
private IAfDispatchService dispatchService;
@Autowired
private IAfOrderService orderService;
@Scheduled(fixedDelay = 10000L, initialDelay = 15000L)
public void scanPendingOrders()
{
@@ -35,6 +43,14 @@ public class AfDispatchScheduler
return;
}
lastDispatchTimestamp = now;
try
{
orderService.syncWeidianOrders(0, "system");
}
catch (Exception e)
{
log.error("定时同步微店订单失败", e);
}
dispatchService.triggerAutoDispatch(null, "CRON", "system");
}
}
@@ -1,16 +1,28 @@
package com.ruoyi.system.service.af.impl;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.system.domain.af.AfDevice;
import com.ruoyi.system.domain.af.AfDispatchTask;
import com.ruoyi.system.domain.af.AfOrder;
import com.ruoyi.system.domain.af.AfStatusConstants;
import com.ruoyi.system.domain.af.AfWeidianProperties;
import com.ruoyi.system.mapper.af.AfDeviceMapper;
import com.ruoyi.system.mapper.af.AfDispatchTaskMapper;
import com.ruoyi.system.mapper.af.AfOrderMapper;
@@ -25,6 +37,10 @@ import com.ruoyi.system.service.af.IAfOrderService;
@Service
public class AfOrderServiceImpl implements IAfOrderService
{
private static final Logger log = LoggerFactory.getLogger(AfOrderServiceImpl.class);
private static final String SOURCE_WEIDIAN = "WEIDIAN";
@Autowired
private AfOrderMapper orderMapper;
@@ -38,6 +54,9 @@ public class AfOrderServiceImpl implements IAfOrderService
@Autowired
private IAfDispatchService dispatchService;
@Autowired
private AfWeidianProperties weidianProperties;
/**
* 查询订单列表。
*
@@ -149,4 +168,260 @@ public class AfOrderServiceImpl implements IAfOrderService
return 1;
}
/**
* 从微店拉取待充值订单并同步入库。
* 调用微店 seller.getOrderListForPC 接口,解析订单列表后逐条 INSERT IGNORE 写入。
* 幂等保证:唯一索引 uk_source_order(source, source_order_no) 防止重复插入。
*
* @param pageNum 页码(从 0 开始)
* @param operator 操作人
* @return 同步结果摘要,包含 total(接口返回总数)、inserted(本次新增数)
*/
@Override
public Map<String, Object> syncWeidianOrders(int pageNum, String operator)
{
log.info("syncWeidianOrders 开始,pageNum={}, operator={}", pageNum, operator);
String responseBody = fetchWeidianOrderList(pageNum);
if (StringUtils.isBlank(responseBody))
{
throw new ServiceException("微店接口请求失败,响应为空");
}
JSONObject root = JSON.parseObject(responseBody);
JSONObject result = root.getJSONObject("result");
if (result == null)
{
log.error("syncWeidianOrders 响应格式异常,无 result 字段,body={}", responseBody);
throw new ServiceException("微店接口响应格式异常");
}
int total = result.getIntValue("total");
JSONArray orderList = result.getJSONArray("orderList");
if (orderList == null || orderList.isEmpty())
{
log.info("syncWeidianOrders 本次无订单数据,total={}", total);
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("total", total);
summary.put("inserted", 0);
return summary;
}
int inserted = 0;
List<String> failedOrderNos = new ArrayList<>();
for (int i = 0; i < orderList.size(); i++)
{
JSONObject item = orderList.getJSONObject(i);
try
{
AfOrder order = buildOrderFromWeidian(item, operator);
int rows = orderMapper.insertAfOrderIgnoreDuplicate(order);
if (rows > 0)
{
inserted++;
log.info("syncWeidianOrders 新增订单,sourceOrderNo={}, rechargeAccount={}, amount={}",
order.getSourceOrderNo(), order.getRechargeAccount(), order.getAmount());
dispatchService.onOrderPending(order.getId());
}
}
catch (Exception e)
{
String orderId = item.getString("orderId");
log.error("syncWeidianOrders 解析或入库失败,orderId={}", orderId, e);
failedOrderNos.add(orderId);
}
}
log.info("syncWeidianOrders 完成,total={}, inserted={}, failed={}", total, inserted, failedOrderNos.size());
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("total", total);
summary.put("inserted", inserted);
if (!failedOrderNos.isEmpty())
{
summary.put("failedOrderNos", failedOrderNos);
}
return summary;
}
/**
* 调用微店订单列表接口,返回原始响应字符串。
*
* @param pageNum 页码(从 0 开始)
* @return 响应字符串
*/
private String fetchWeidianOrderList(int pageNum)
{
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/x-www-form-urlencoded; charset=UTF-8");
headers.put("cookie", weidianProperties.getCookie());
headers.put("origin", "https://d.weidian.com");
headers.put("referer", "https://d.weidian.com/");
headers.put("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
JSONObject paramJson = new JSONObject();
paramJson.put("listType", 0);
paramJson.put("pageNum", pageNum);
paramJson.put("pageSize", weidianProperties.getPageSize());
paramJson.put("statusList", new String[]{"paid"});
paramJson.put("refundStatusList", new String[]{});
paramJson.put("channel", "pc");
paramJson.put("topOrderType", 0);
paramJson.put("shipRole", 0);
paramJson.put("orderIdList", "");
paramJson.put("itemTitle", "");
paramJson.put("buyerName", "");
paramJson.put("timeSearch", new JSONObject());
paramJson.put("orderBizType", "");
paramJson.put("promotionType", "");
paramJson.put("shipType", "");
paramJson.put("newGhSearchSellerRole", "7");
paramJson.put("memberLevel", "");
paramJson.put("orderSpecialType", "");
paramJson.put("repayStatus", "2");
paramJson.put("pushStatus", "1");
paramJson.put("bSellerId", "");
paramJson.put("itemSource", "");
paramJson.put("shipper", "");
paramJson.put("nSellerName", "");
paramJson.put("partnerName", "");
JSONObject noteCondition = new JSONObject();
noteCondition.put("buyerNote", "");
paramJson.put("noteSearchCondition", noteCondition);
JSONObject specialCondition = new JSONObject();
specialCondition.put("notShowGroupUnsuccess", 0);
specialCondition.put("notShowFxOrder", 0);
specialCondition.put("notShowUnRepayOrder", 0);
specialCondition.put("notShowBuyerRepayOrder", 0);
specialCondition.put("showAllPeriodOrder", 0);
specialCondition.put("notShowTencentShopOrder", 0);
specialCondition.put("notShowWithoutTimelinessOrder", 0);
paramJson.put("specialOrderSearchCondition", specialCondition);
paramJson.put("orderType", 4);
String body;
try
{
body = "param=" + java.net.URLEncoder.encode(paramJson.toJSONString(), "UTF-8")
+ "&wdtoken=" + weidianProperties.getWdToken();
}
catch (java.io.UnsupportedEncodingException ignored)
{
throw new ServiceException("URL编码失败");
}
return HttpUtils.sendPostWithHeaders(weidianProperties.getOrderListUrl(), body, headers);
}
/**
* 将微店订单 JSON 对象转换为 AfOrder 实体。
* 字段映射:
* orderId -> sourceOrderNo
* customInfo[name=抖音号].value -> rechargeAccount
* totalPrice -> amount
*
* @param item 微店单条订单 JSON
* @param operator 操作人
* @return AfOrder 实体
*/
private AfOrder buildOrderFromWeidian(JSONObject item, String operator)
{
AfOrder order = new AfOrder();
order.setSource(SOURCE_WEIDIAN);
String orderId = item.getString("orderId");
order.setSourceOrderNo(orderId);
order.setOrderNo(orderId);
String rechargeAccount = extractDouyinAccount(item.getJSONArray("customInfo"));
order.setRechargeAccount(StringUtils.isNotBlank(rechargeAccount) ? rechargeAccount : "");
BigDecimal totalPrice = item.getBigDecimal("totalPrice");
order.setAmount(totalPrice != null ? totalPrice : BigDecimal.ZERO);
order.setStatus(AfStatusConstants.ORDER_PENDING);
order.setDispatchMode("AUTO");
order.setExtJson(item.toJSONString());
order.setCreateBy(operator);
return order;
}
/**
* 从 customInfo 数组中提取 name=抖音号 的 value。
*
* @param customInfo 微店订单 customInfo 数组
* @return 抖音号,未找到时返回空字符串
*/
private String extractDouyinAccount(JSONArray customInfo)
{
if (customInfo == null || customInfo.isEmpty())
{
return "";
}
for (int i = 0; i < customInfo.size(); i++)
{
JSONObject entry = customInfo.getJSONObject(i);
if ("抖音号".equals(entry.getString("name")))
{
return entry.getString("value");
}
}
return "";
}
@Override
public void deliverWeidianOrder(String sourceOrderNo, String operator)
{
if (StringUtils.isBlank(sourceOrderNo))
{
log.warn("deliverWeidianOrder 订单号为空,跳过");
return;
}
log.info("deliverWeidianOrder 开始,sourceOrderNo={}, operator={}", sourceOrderNo, operator);
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/x-www-form-urlencoded; charset=UTF-8");
headers.put("cookie", weidianProperties.getCookie());
headers.put("origin", "https://d.weidian.com");
headers.put("referer", "https://d.weidian.com/");
headers.put("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
JSONObject paramJson = new JSONObject();
paramJson.put("from", "pc");
paramJson.put("orderId", sourceOrderNo);
paramJson.put("expressNo", "");
paramJson.put("expressType", 0);
paramJson.put("expressCustom", "");
paramJson.put("fullDeliver", true);
String body;
try
{
body = "param=" + java.net.URLEncoder.encode(paramJson.toJSONString(), "UTF-8")
+ "&wdtoken=" + weidianProperties.getWdToken();
}
catch (java.io.UnsupportedEncodingException ignored)
{
throw new ServiceException("URL编码失败");
}
String responseBody = HttpUtils.sendPostWithHeaders(weidianProperties.getDeliverOrderUrl(), body, headers);
if (StringUtils.isBlank(responseBody))
{
log.error("deliverWeidianOrder 请求失败,响应为空,sourceOrderNo={}", sourceOrderNo);
throw new ServiceException("微店发货接口请求失败");
}
JSONObject root = JSON.parseObject(responseBody);
JSONObject status = root.getJSONObject("status");
if (status == null || status.getIntValue("code") != 0)
{
String message = status != null ? status.getString("message") : "未知错误";
log.error("deliverWeidianOrder 失败,sourceOrderNo={}, response={}", sourceOrderNo, responseBody);
throw new ServiceException("微店发货失败:" + message);
}
log.info("deliverWeidianOrder 成功,sourceOrderNo={}", sourceOrderNo);
}
}
@@ -3,6 +3,8 @@ package com.ruoyi.system.service.af.impl;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
@@ -19,6 +21,7 @@ import com.ruoyi.system.mapper.af.AfDispatchTaskMapper;
import com.ruoyi.system.mapper.af.AfOrderMapper;
import com.ruoyi.system.mapper.af.AfTaskStepLogMapper;
import com.ruoyi.system.service.af.IAfDispatchService;
import com.ruoyi.system.service.af.IAfOrderService;
import com.ruoyi.system.service.af.IAfTaskService;
/**
@@ -29,6 +32,8 @@ import com.ruoyi.system.service.af.IAfTaskService;
@Service
public class AfTaskServiceImpl implements IAfTaskService
{
private static final Logger log = LoggerFactory.getLogger(AfTaskServiceImpl.class);
private static final int DEVICE_COOLDOWN_SECONDS = 10;
@Value("${af.rechargeUrl:}")
@@ -50,6 +55,10 @@ public class AfTaskServiceImpl implements IAfTaskService
@Autowired
private IAfDispatchService dispatchService;
@Lazy
@Autowired
private IAfOrderService orderService;
/**
* 查询任务列表。
*
@@ -334,6 +343,23 @@ public class AfTaskServiceImpl implements IAfTaskService
? AfStatusConstants.ORDER_SUCCESS : AfStatusConstants.ORDER_FAILED;
orderMapper.updateOrderStatusByExpect(task.getOrderId(), AfStatusConstants.ORDER_PROCESSING, orderTargetStatus, failReason, operator);
// 充值成功后通知微店发货
if (StringUtils.equals(resultStatus, AfStatusConstants.TASK_SUCCESS) && task.getOrderId() != null)
{
com.ruoyi.system.domain.af.AfOrder order = orderMapper.selectAfOrderById(task.getOrderId());
if (order != null && "WEIDIAN".equals(order.getSource()))
{
try
{
orderService.deliverWeidianOrder(order.getSourceOrderNo(), operator);
}
catch (Exception e)
{
log.error("微店发货通知失败,sourceOrderNo={},不影响订单状态", order.getSourceOrderNo(), e);
}
}
}
// 联动设备状态 → IDLE,并使用数据库时间写入固定 10 秒冷却,避免应用时区与数据库时区不一致时放大冷却时长。
AfDevice device = deviceMapper.selectByUserNameAndDeviceCode(task.getUserName(), task.getDeviceCode());
if (StringUtils.isNotNull(device))
@@ -138,4 +138,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where id = #{id}
</update>
<insert id="insertAfOrderIgnoreDuplicate" parameterType="com.ruoyi.system.domain.af.AfOrder" useGeneratedKeys="true" keyProperty="id">
insert ignore into af_order
(source, source_order_no, order_no, recharge_account, amount, status, dispatch_mode, ext_json, create_by, create_time, update_by, update_time)
values
(#{source}, #{sourceOrderNo}, #{orderNo}, #{rechargeAccount}, #{amount}, #{status}, #{dispatchMode}, #{extJson}, #{createBy}, sysdate(), #{createBy}, sysdate())
</insert>
</mapper>