Compare commits

...

51 Commits

Author SHA1 Message Date
van
e78cc67476 1 2026-05-09 01:31:01 +08:00
van
6fb46cc203 1 2026-05-07 18:35:40 +08:00
van
7582868b2c 1 2026-05-06 17:38:58 +08:00
van
9d03cca517 1 2026-05-06 01:51:30 +08:00
van
8b5abb44ee 1 2026-05-05 14:58:52 +08:00
van
e75f71d37b 1 2026-04-30 17:53:31 +08:00
van
5da74a155c 1 2026-04-30 17:35:50 +08:00
van
a88600788a 1 2026-04-30 17:10:34 +08:00
van
cf8008bdc1 1 2026-04-26 14:08:26 +08:00
van
d97a977a0e 1 2026-04-23 23:53:11 +08:00
van
1f25cc5d15 1 2026-04-23 23:26:55 +08:00
van
66aa339906 1 2026-04-23 22:20:34 +08:00
van
acd693f122 1 2026-04-23 21:55:47 +08:00
van
751844493b 1 2026-04-23 21:48:23 +08:00
van
256b54ffab 1 2026-04-23 16:06:33 +08:00
van
0ff357148b 1 2026-04-23 16:04:45 +08:00
van
7cd7440f1f 1 2026-04-23 16:00:44 +08:00
van
f5f14c730f 1 2026-04-22 11:23:34 +08:00
van
a7068053e1 1 2026-04-22 00:57:24 +08:00
van
babe687679 1 2026-04-22 00:55:51 +08:00
van
de335831d4 1 2026-04-21 23:37:18 +08:00
van
a10d561fcb 1 2026-04-12 23:17:56 +08:00
van
656f3d28a9 1 2026-04-12 16:58:05 +08:00
van
e420aaeb9e 1 2026-04-12 16:50:17 +08:00
van
01bea5005e 1 2026-04-12 16:45:55 +08:00
van
c825c6b81a 1 2026-04-12 01:19:10 +08:00
van
1446ea2432 1 2026-04-11 22:55:40 +08:00
van
52b8f13b2d 1 2026-04-11 22:47:16 +08:00
van
94f319514e 1 2026-04-11 22:35:39 +08:00
van
5205d8c155 1 2026-04-11 00:48:37 +08:00
van
fed0158444 1 2026-04-10 17:29:02 +08:00
van
24cf538475 1 2026-04-10 17:18:03 +08:00
van
c50975bce5 1 2026-04-10 16:59:14 +08:00
van
042068ccf1 1 2026-04-10 01:00:10 +08:00
van
52d0adfc85 1 2026-04-10 00:57:31 +08:00
van
ede30b5f36 1 2026-04-10 00:40:34 +08:00
van
ce3af838bd 1 2026-04-10 00:39:44 +08:00
van
0205fe2c09 1 2026-04-10 00:29:04 +08:00
van
6f482256c5 1 2026-04-10 00:09:26 +08:00
van
31e7e6853b 1 2026-04-09 01:19:15 +08:00
van
a2c4589046 1 2026-04-09 01:11:23 +08:00
van
16bcd45c63 1 2026-04-09 00:28:59 +08:00
van
e94f17973c 1 2026-04-09 00:09:09 +08:00
van
c9876df3de 1 2026-04-08 16:36:20 +08:00
van
2d4f933791 1 2026-04-07 21:35:36 +08:00
van
a22d17de73 1 2026-04-07 17:29:32 +08:00
van
9af1a369f7 1 2026-04-06 16:16:44 +08:00
van
49c855ff78 1 2026-04-06 11:36:57 +08:00
van
0838d16652 1 2026-04-06 11:15:07 +08:00
van
6a43af0a34 1 2026-04-05 23:22:21 +08:00
van
1b4a73cd25 1 2026-04-05 23:13:47 +08:00
102 changed files with 6241 additions and 351 deletions

View File

@@ -35,6 +35,7 @@
<logback.version>1.2.13</logback.version> <logback.version>1.2.13</logback.version>
<spring-security.version>5.7.12</spring-security.version> <spring-security.version>5.7.12</spring-security.version>
<spring-framework.version>5.3.39</spring-framework.version> <spring-framework.version>5.3.39</spring-framework.version>
<rocketmq-spring.version>2.2.3</rocketmq-spring.version>
</properties> </properties>
<!-- 依赖声明 --> <!-- 依赖声明 -->
@@ -218,6 +219,12 @@
<version>${ruoyi.version}</version> <version>${ruoyi.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>${rocketmq-spring.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@@ -5,6 +5,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
@@ -14,6 +15,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*/ */
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }) @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
@EnableScheduling @EnableScheduling
@EnableAsync
public class RuoYiApplication public class RuoYiApplication
{ {
public static void main(String[] args) public static void main(String[] args)

View File

@@ -1,74 +1,129 @@
package com.ruoyi.web.controller.common; package com.ruoyi.web.controller.common;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.erp.request.IERPAccount;
import com.ruoyi.jarvis.service.IErpGoofishOrderService;
import com.ruoyi.jarvis.service.erp.ErpAccountResolver;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
/** /**
* 开放平台回调接收端 * 闲管家开放平台推送回调(请在开放平台填写真实 URL
* 注意:/product/receive 与 /order/receive 为示例路径,请在开放平台配置时使用你自己的正式回调地址 * 订单POST .../open/callback/order/receive?appid=&timestamp=&sign=
* <p>
* 成功/失败体须与《订单推送通知》OpenAPI 一致:{@code result=success|fail} + {@code msg}
* 仅当 {@code result} 为 success 时平台认为接收成功(失败最多重试 3 次;建议业务异步处理、快速返回)。
*/ */
@Anonymous
@RestController @RestController
@RequestMapping("/open/callback") @RequestMapping("/open/callback")
public class OpenCallbackController extends BaseController { public class OpenCallbackController {
@Resource
private ErpAccountResolver erpAccountResolver;
@Resource
private IErpGoofishOrderService erpGoofishOrderService;
@PostMapping("/product/receive") @PostMapping("/product/receive")
public JSONObject receiveProductCallback( public JSONObject receiveProductCallback(
@RequestParam("appid") String appid, @RequestParam("appid") String appid,
@RequestParam(value = "timestamp", required = false) Long timestamp, @RequestParam(value = "timestamp", required = false) Long timestamp,
@RequestParam(value = "seller_id", required = false) Long sellerId,
@RequestParam("sign") String sign, @RequestParam("sign") String sign,
@RequestBody JSONObject body @RequestBody(required = false) String rawBody
) { ) {
if (!verifySign(appid, timestamp, sign, body)) { String normalizedBody = normalizeJsonBody(rawBody);
JSONObject fail = new JSONObject(); IERPAccount account = erpAccountResolver.resolveStrict(appid);
fail.put("result", "fail"); if (!verifyGoofishSign(account, timestamp, sellerId, sign, normalizedBody)) {
fail.put("msg", "签名失败"); return failCallback("签名失败");
return fail;
} }
JSONObject ok = new JSONObject(); return successCallback();
ok.put("result", "success");
ok.put("msg", "接收成功");
return ok;
} }
@PostMapping("/order/receive") @PostMapping("/order/receive")
public JSONObject receiveOrderCallback( public JSONObject receiveOrderCallback(
@RequestParam("appid") String appid, @RequestParam("appid") String appid,
@RequestParam(value = "timestamp", required = false) Long timestamp, @RequestParam(value = "timestamp", required = false) Long timestamp,
@RequestParam(value = "seller_id", required = false) Long sellerId,
@RequestParam("sign") String sign, @RequestParam("sign") String sign,
@RequestBody JSONObject body @RequestBody(required = false) String rawBody
) { ) {
if (!verifySign(appid, timestamp, sign, body)) { String normalizedBody = normalizeJsonBody(rawBody);
JSONObject fail = new JSONObject(); IERPAccount account = erpAccountResolver.resolveStrict(appid);
fail.put("result", "fail"); if (account == null) {
fail.put("msg", "签名失败"); return failCallback("未找到启用的 AppKey 配置");
return fail;
} }
if (!verifyGoofishSign(account, timestamp, sellerId, sign, normalizedBody)) {
return failCallback("签名失败");
}
JSONObject body;
try {
body = "{}".equals(normalizedBody) ? new JSONObject() : JSON.parseObject(normalizedBody);
} catch (Exception e) {
return failCallback("请求体不是合法JSON");
}
try {
erpGoofishOrderService.publishOrProcessNotify(appid, timestamp, body);
} catch (Exception e) {
return failCallback("入队异常");
}
return successCallback();
}
/**
* 与开放平台示例一致:签名字符串 = md5(appKey + "," + md5(body原文) + "," + timestamp + [,sellerId] + "," + appSecret)
* body 须与推送原文完全一致后做 MD5不能用解析后再 toJSONString字段顺序变化会导致验签失败
*/
private boolean verifyGoofishSign(IERPAccount account, Long timestamp, Long sellerId, String sign, String bodyExactForMd5) {
if (account == null || StringUtils.isEmpty(sign)) {
return false;
}
String jsonForMd5 = bodyExactForMd5 == null || bodyExactForMd5.isEmpty() ? "{}" : bodyExactForMd5;
String bodyMd5 = md5Hex(jsonForMd5);
long ts = timestamp == null ? 0L : timestamp;
String data;
if (sellerId != null) {
data = account.getApiKey() + "," + bodyMd5 + "," + ts + "," + sellerId + "," + account.getApiKeySecret();
} else {
data = account.getApiKey() + "," + bodyMd5 + "," + ts + "," + account.getApiKeySecret();
}
String local = md5Hex(data);
return StringUtils.equalsIgnoreCase(local, sign.trim());
}
private static String normalizeJsonBody(String rawBody) {
if (rawBody == null) {
return "{}";
}
String t = rawBody.trim();
return t.isEmpty() ? "{}" : t;
}
/** 与《订单推送通知》notify_resp_ok 一致result=success 时平台才停止重试 */
private static JSONObject successCallback() {
JSONObject ok = new JSONObject(); JSONObject ok = new JSONObject();
ok.put("result", "success"); ok.put("result", "success");
ok.put("msg", "接收成功"); ok.put("msg", "接收成功");
return ok; return ok;
} }
private boolean verifySign(String appid, Long timestamp, String sign, JSONObject body) { /** 与 notify_resp_fail 一致result=fail */
// TODO: 这里需要根据appid查出对应的 appKey/appSecret private static JSONObject failCallback(String msg) {
// 为了示例,直接使用 ERPAccount.ACCOUNT_HUGE 的常量。生产请替换为从数据库/配置读取 JSONObject j = new JSONObject();
String appKey = "1016208368633221"; j.put("result", "fail");
String appSecret = "waLiRMgFcixLbcLjUSSwo370Hp1nBcBu"; j.put("msg", msg == null || msg.isEmpty() ? "处理失败" : msg);
return j;
String json = body == null ? "{}" : body.toJSONString();
String data = appKey + "," + md5(json) + "," + (timestamp == null ? 0 : timestamp) + "," + appSecret;
String local = md5(data);
return StringUtils.equalsIgnoreCase(local, sign);
} }
private String md5(String str) { private String md5Hex(String str) {
try { try {
MessageDigest md = MessageDigest.getInstance("MD5"); MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(str.getBytes(StandardCharsets.UTF_8)); byte[] digest = md.digest(str.getBytes(StandardCharsets.UTF_8));
@@ -82,5 +137,3 @@ public class OpenCallbackController extends BaseController {
} }
} }
} }

View File

@@ -11,6 +11,10 @@ import com.ruoyi.common.utils.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import com.ruoyi.erp.request.ERPAccount; import com.ruoyi.erp.request.ERPAccount;
import com.ruoyi.erp.request.IERPAccount;
import com.ruoyi.jarvis.domain.ErpOpenConfig;
import com.ruoyi.jarvis.service.IErpOpenConfigService;
import com.ruoyi.jarvis.service.erp.ErpAccountResolver;
import com.ruoyi.erp.request.ProductCreateRequest; import com.ruoyi.erp.request.ProductCreateRequest;
import com.ruoyi.erp.request.ProductCategoryListQueryRequest; import com.ruoyi.erp.request.ProductCategoryListQueryRequest;
import com.ruoyi.erp.request.ProductPropertyListQueryRequest; import com.ruoyi.erp.request.ProductPropertyListQueryRequest;
@@ -20,6 +24,7 @@ import com.ruoyi.erp.request.ProductDownShelfRequest;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.constraints.*; import javax.validation.constraints.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@@ -37,12 +42,18 @@ public class ProductController extends BaseController {
@Autowired @Autowired
private IOuterIdGeneratorService outerIdGeneratorService; private IOuterIdGeneratorService outerIdGeneratorService;
@Resource
private ErpAccountResolver erpAccountResolver;
@Resource
private IErpOpenConfigService erpOpenConfigService;
private static final Logger log = LoggerFactory.getLogger(ProductController.class); private static final Logger log = LoggerFactory.getLogger(ProductController.class);
@PostMapping("/createByPromotion") @PostMapping("/createByPromotion")
public R<?> createByPromotion(@RequestBody @Validated CreateProductFromPromotionRequest req) { public R<?> createByPromotion(@RequestBody @Validated CreateProductFromPromotionRequest req) {
try { try {
ERPAccount account = resolveAccount(req.getAppid()); IERPAccount account = resolveAccount(req.getAppid());
// 1) 组装 ERPShop // 1) 组装 ERPShop
ERPShop erpShop = new ERPShop(); ERPShop erpShop = new ERPShop();
erpShop.setChannelCatid(req.getChannelCatId()); erpShop.setChannelCatid(req.getChannelCatId());
@@ -143,7 +154,7 @@ public class ProductController extends BaseController {
@PostMapping("/publish") @PostMapping("/publish")
public R<?> publish(@RequestBody @Validated PublishRequest req) { public R<?> publish(@RequestBody @Validated PublishRequest req) {
try { try {
ERPAccount account = resolveAccount(req.getAppid()); IERPAccount account = resolveAccount(req.getAppid());
ProductPublishRequest publishRequest = new ProductPublishRequest(account); ProductPublishRequest publishRequest = new ProductPublishRequest(account);
publishRequest.setProductId(req.getProductId()); publishRequest.setProductId(req.getProductId());
publishRequest.setUserName(req.getUserName()); publishRequest.setUserName(req.getUserName());
@@ -344,12 +355,24 @@ public class ProductController extends BaseController {
String name = firstNonBlank(row.getString("user_name"), row.getString("xy_name"), row.getString("username"), row.getString("nick")); String name = firstNonBlank(row.getString("user_name"), row.getString("xy_name"), row.getString("username"), row.getString("nick"));
if (name != null) { if (name != null) {
String label = name; String label = name;
List<ErpOpenConfig> cfgs = erpOpenConfigService.selectEnabledOrderBySort();
if (cfgs != null) {
for (ErpOpenConfig c : cfgs) {
if (name.equals(c.getXyUserName())) {
String r = c.getRemark() != null ? c.getRemark() : c.getAppKey();
label = name + "(" + r + ")";
break;
}
}
}
if (label.equals(name)) {
for (ERPAccount a : ERPAccount.values()) { for (ERPAccount a : ERPAccount.values()) {
if (name.equals(a.getXyName())) { if (name.equals(a.getXyName())) {
label = name + "(" + a.getRemark() + ")"; label = name + "(" + a.getRemark() + ")";
break; break;
} }
} }
}
options.add(new Option(name, label)); options.add(new Option(name, label));
} }
}; };
@@ -376,20 +399,24 @@ public class ProductController extends BaseController {
@GetMapping("/ERPAccount") @GetMapping("/ERPAccount")
public R<?> erpAccounts() { public R<?> erpAccounts() {
java.util.List<Option> list = new java.util.ArrayList<>(); java.util.List<Option> list = new java.util.ArrayList<>();
List<ErpOpenConfig> cfgs = erpOpenConfigService.selectEnabledOrderBySort();
if (cfgs != null) {
for (ErpOpenConfig c : cfgs) {
String label = StringUtils.isNotEmpty(c.getRemark()) ? c.getRemark() : c.getXyUserName();
if (StringUtils.isEmpty(label)) {
label = c.getAppKey();
}
list.add(new Option(c.getAppKey(), "【配置】" + label));
}
}
for (ERPAccount a : ERPAccount.values()) { for (ERPAccount a : ERPAccount.values()) {
// 仅显示备注作为 labelvalue 仍为 appid list.add(new Option(a.getApiKey(), "【内置】" + a.getRemark()));
list.add(new Option(a.getApiKey(), a.getRemark()));
} }
return R.ok(list); return R.ok(list);
} }
private ERPAccount resolveAccount(String appid) { private IERPAccount resolveAccount(String appid) {
if (appid != null && !appid.isEmpty()) { return erpAccountResolver.resolve(appid);
for (ERPAccount a : ERPAccount.values()) {
if (a.getApiKey().equals(appid)) return a;
}
}
return ERPAccount.ACCOUNT_HUGE;
} }
/** /**
@@ -558,7 +585,7 @@ public class ProductController extends BaseController {
@PostMapping("/downShelf") @PostMapping("/downShelf")
public R<?> downShelf(@RequestBody @Validated DownShelfRequest req) { public R<?> downShelf(@RequestBody @Validated DownShelfRequest req) {
try { try {
ERPAccount account = resolveAccount(req.getAppid()); IERPAccount account = resolveAccount(req.getAppid());
ProductDownShelfRequest downShelfRequest = new ProductDownShelfRequest(account); ProductDownShelfRequest downShelfRequest = new ProductDownShelfRequest(account);
downShelfRequest.setProductId(req.getProductId()); downShelfRequest.setProductId(req.getProductId());
String resp = downShelfRequest.getResponseBody(); String resp = downShelfRequest.getResponseBody();
@@ -576,7 +603,7 @@ public class ProductController extends BaseController {
@PostMapping("/batchPublish") @PostMapping("/batchPublish")
public R<?> batchPublish(@RequestBody @Validated BatchPublishRequest req) { public R<?> batchPublish(@RequestBody @Validated BatchPublishRequest req) {
try { try {
ERPAccount account = resolveAccount(req.getAppid()); IERPAccount account = resolveAccount(req.getAppid());
List<Long> productIds = req.getProductIds(); List<Long> productIds = req.getProductIds();
if (productIds == null || productIds.isEmpty()) { if (productIds == null || productIds.isEmpty()) {
@@ -651,7 +678,7 @@ public class ProductController extends BaseController {
@PostMapping("/batchDownShelf") @PostMapping("/batchDownShelf")
public R<?> batchDownShelf(@RequestBody @Validated BatchDownShelfRequest req) { public R<?> batchDownShelf(@RequestBody @Validated BatchDownShelfRequest req) {
try { try {
ERPAccount account = resolveAccount(req.getAppid()); IERPAccount account = resolveAccount(req.getAppid());
List<Long> productIds = req.getProductIds(); List<Long> productIds = req.getProductIds();
if (productIds == null || productIds.isEmpty()) { if (productIds == null || productIds.isEmpty()) {

View File

@@ -0,0 +1,126 @@
package com.ruoyi.web.controller.jarvis;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.jarvis.config.JarvisGoofishProperties;
import com.ruoyi.jarvis.domain.ErpGoofishOrder;
import com.ruoyi.jarvis.domain.ErpGoofishOrderEventLog;
import com.ruoyi.jarvis.domain.ErpGoofishOrderEventLogQuery;
import com.ruoyi.jarvis.service.IErpGoofishOrderService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/jarvis/erpGoofishOrder")
public class ErpGoofishOrderController extends BaseController {
@Resource
private IErpGoofishOrderService erpGoofishOrderService;
@Resource
private JarvisGoofishProperties goofishProperties;
/**
* 订单变更日志全表检索(跨订单排查),须配置在 /{id} 之前避免歧义;对应日志标记 [goofish-order-event]
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:list')")
@GetMapping("/eventLog/list")
public TableDataInfo eventLogList(ErpGoofishOrderEventLogQuery query) {
startPage();
List<ErpGoofishOrderEventLog> list = erpGoofishOrderService.selectEventLogList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:list')")
@GetMapping("/list")
public TableDataInfo list(ErpGoofishOrder query) {
startPage();
List<ErpGoofishOrder> list = erpGoofishOrderService.selectList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable Long id) {
return AjaxResult.success(erpGoofishOrderService.selectById(id));
}
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:query')")
@GetMapping("/{id}/eventLogs")
public AjaxResult eventLogs(@PathVariable Long id) {
List<ErpGoofishOrderEventLog> list = erpGoofishOrderService.listEventLogsByOrderId(id);
return AjaxResult.success(list);
}
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:edit')")
@Log(title = "闲管家拉单", businessType = BusinessType.OTHER)
@PostMapping("/pull/{appKey}")
public AjaxResult pullOne(@PathVariable String appKey, @RequestParam(value = "hours", required = false) Integer hours) {
int h = hours == null ? goofishProperties.getPullLookbackHours() : hours;
int n = erpGoofishOrderService.pullOrdersForAppKey(appKey, h);
Map<String, Object> data = new LinkedHashMap<>();
data.put("processedItems", n);
data.put("lookbackHours", h);
return AjaxResult.success(data);
}
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:edit')")
@Log(title = "闲管家全量拉单", businessType = BusinessType.OTHER)
@PostMapping("/pullAll")
public AjaxResult pullAll(@RequestParam(value = "hours", required = false) Integer hours) {
int h = hours == null ? goofishProperties.getPullLookbackHours() : hours;
int n = erpGoofishOrderService.pullAllEnabled(h);
Map<String, Object> data = new LinkedHashMap<>();
data.put("processedItems", n);
data.put("lookbackHours", h);
return AjaxResult.success(data);
}
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:edit')")
@Log(title = "闲管家历史全量拉单", businessType = BusinessType.OTHER)
@PostMapping("/pull/{appKey}/full")
public AjaxResult pullOneFull(@PathVariable String appKey) {
int n = erpGoofishOrderService.pullOrdersForAppKeyFullHistory(appKey);
Map<String, Object> data = new LinkedHashMap<>();
data.put("processedItems", n);
data.put("pullFullHistoryDays", goofishProperties.getPullFullHistoryDays());
data.put("pullTimeChunkSeconds", goofishProperties.getPullTimeChunkSeconds());
return AjaxResult.success(data);
}
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:edit')")
@Log(title = "闲管家全账号历史全量拉单", businessType = BusinessType.OTHER)
@PostMapping("/pullAll/full")
public AjaxResult pullAllFull() {
int n = erpGoofishOrderService.pullAllEnabledFullHistory();
Map<String, Object> data = new LinkedHashMap<>();
data.put("processedItems", n);
data.put("pullFullHistoryDays", goofishProperties.getPullFullHistoryDays());
data.put("pullTimeChunkSeconds", goofishProperties.getPullTimeChunkSeconds());
return AjaxResult.success(data);
}
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:edit')")
@Log(title = "闲管家订单详情刷新", businessType = BusinessType.UPDATE)
@PostMapping("/refreshDetail/{id}")
public AjaxResult refreshDetail(@PathVariable Long id) {
erpGoofishOrderService.refreshDetail(id);
return AjaxResult.success();
}
@PreAuthorize("@ss.hasPermi('jarvis:erpGoofishOrder:edit')")
@Log(title = "闲管家重试发货", businessType = BusinessType.UPDATE)
@PostMapping("/retryShip/{id}")
public AjaxResult retryShip(@PathVariable Long id) {
erpGoofishOrderService.retryShip(id);
return AjaxResult.success();
}
}

View File

@@ -0,0 +1,95 @@
package com.ruoyi.web.controller.jarvis;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.erp.request.ExpressCompaniesQueryRequest;
import com.ruoyi.erp.request.IERPAccount;
import com.ruoyi.jarvis.domain.ErpOpenConfig;
import com.ruoyi.jarvis.service.IErpOpenConfigService;
import com.ruoyi.jarvis.service.erp.ErpAccountResolver;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
@RestController
@RequestMapping("/jarvis/erpOpenConfig")
public class ErpOpenConfigController extends BaseController {
@Resource
private IErpOpenConfigService erpOpenConfigService;
@Resource
private ErpAccountResolver erpAccountResolver;
/**
* 查询闲管家快递公司列表POST body 固定为 {},与签名规则一致)
*
* @param appKey 与配置中心一致;不传则按 {@link ErpAccountResolver#resolve(String)} 默认账号
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpOpenConfig:list')")
@GetMapping("/expressCompanies")
public AjaxResult expressCompanies(@RequestParam(required = false) String appKey) {
try {
IERPAccount cred = erpAccountResolver.resolve(appKey);
ExpressCompaniesQueryRequest req = new ExpressCompaniesQueryRequest(cred);
String resp = req.getResponseBody();
JSONObject o = JSONObject.parseObject(resp);
if (o == null) {
return AjaxResult.error("开放平台返回空");
}
if (o.getIntValue("code") != 0) {
return AjaxResult.error(o.getString("msg") != null ? o.getString("msg") : resp);
}
return AjaxResult.success(o.get("data"));
} catch (Exception e) {
return AjaxResult.error("调用快递公司接口失败: " + e.getMessage());
}
}
@PreAuthorize("@ss.hasPermi('jarvis:erpOpenConfig:list')")
@GetMapping("/list")
public TableDataInfo list(ErpOpenConfig query) {
startPage();
List<ErpOpenConfig> list = erpOpenConfigService.selectList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('jarvis:erpOpenConfig:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable Long id) {
return AjaxResult.success(erpOpenConfigService.selectById(id));
}
@PreAuthorize("@ss.hasPermi('jarvis:erpOpenConfig:add')")
@Log(title = "闲管家应用配置", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody ErpOpenConfig row) {
return toAjax(erpOpenConfigService.insert(row));
}
@PreAuthorize("@ss.hasPermi('jarvis:erpOpenConfig:edit')")
@Log(title = "闲管家应用配置", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody ErpOpenConfig row) {
return toAjax(erpOpenConfigService.update(row));
}
@PreAuthorize("@ss.hasPermi('jarvis:erpOpenConfig:remove')")
@Log(title = "闲管家应用配置", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids) {
int n = 0;
if (ids != null) {
for (Long id : ids) {
n += erpOpenConfigService.deleteById(id);
}
}
return toAjax(n);
}
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.web.controller.jarvis; package com.ruoyi.web.controller.jarvis;
import java.util.List; import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -17,7 +18,9 @@ import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.jarvis.domain.ErpProduct; import com.ruoyi.jarvis.domain.ErpProduct;
import com.ruoyi.jarvis.domain.ErpProductExportRow;
import com.ruoyi.jarvis.service.IErpProductService; import com.ruoyi.jarvis.service.IErpProductService;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.core.page.TableDataInfo;
@@ -51,12 +54,14 @@ public class ErpProductController extends BaseController
*/ */
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:export')") @PreAuthorize("@ss.hasPermi('jarvis:erpProduct:export')")
@Log(title = "闲鱼商品", businessType = BusinessType.EXPORT) @Log(title = "闲鱼商品", businessType = BusinessType.EXPORT)
@GetMapping("/export") @PostMapping("/export")
public AjaxResult export(ErpProduct erpProduct) public void export(HttpServletResponse response, ErpProduct erpProduct)
{ {
List<ErpProduct> list = erpProductService.selectErpProductList(erpProduct); List<ErpProduct> list = erpProductService.selectErpProductList(erpProduct);
ExcelUtil<ErpProduct> util = new ExcelUtil<ErpProduct>(ErpProduct.class); String batchAt = DateUtils.getTime();
return util.exportExcel(list, "闲鱼商品数据"); List<ErpProductExportRow> rows = ErpProductExportRow.fromList(list, batchAt);
ExcelUtil<ErpProductExportRow> util = new ExcelUtil<ErpProductExportRow>(ErpProductExportRow.class);
util.exportExcel(response, rows, "闲鱼商品_AI明细");
} }
/** /**

View File

@@ -28,8 +28,8 @@ public class InstructionController extends BaseController {
public AjaxResult execute(@RequestBody Map<String, Object> body) { public AjaxResult execute(@RequestBody Map<String, Object> body) {
String cmd = body != null ? (body.get("command") != null ? String.valueOf(body.get("command")) : null) : null; String cmd = body != null ? (body.get("command") != null ? String.valueOf(body.get("command")) : null) : null;
boolean forceGenerate = body != null && body.get("forceGenerate") != null && Boolean.parseBoolean(String.valueOf(body.get("forceGenerate"))); boolean forceGenerate = body != null && body.get("forceGenerate") != null && Boolean.parseBoolean(String.valueOf(body.get("forceGenerate")));
// 控制台入口,传递 isFromConsole=true跳过订单查询校验 // 控制台入口:全量统计视角(排除后台标记不参与统计的联盟),非单个企微成员
java.util.List<String> result = instructionService.execute(cmd, forceGenerate, true); java.util.List<String> result = instructionService.execute(cmd, forceGenerate, true, null);
return AjaxResult.success(result); return AjaxResult.success(result);
} }

View File

@@ -10,6 +10,7 @@ import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.OrderRows; import com.ruoyi.jarvis.domain.OrderRows;
import com.ruoyi.jarvis.service.IJDOrderService; import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.ILogisticsService;
import com.ruoyi.jarvis.service.IOrderRowsService; import com.ruoyi.jarvis.service.IOrderRowsService;
import com.ruoyi.jarvis.service.IGiftCouponService; import com.ruoyi.jarvis.service.IGiftCouponService;
import com.ruoyi.jarvis.domain.GiftCoupon; import com.ruoyi.jarvis.domain.GiftCoupon;
@@ -41,6 +42,7 @@ public class JDOrderController extends BaseController {
private final IOrderRowsService orderRowsService; private final IOrderRowsService orderRowsService;
private final IGiftCouponService giftCouponService; private final IGiftCouponService giftCouponService;
private final ISysConfigService sysConfigService; private final ISysConfigService sysConfigService;
private final ILogisticsService logisticsService;
private static final String CONFIG_KEY_PREFIX = "logistics.push.touser."; private static final String CONFIG_KEY_PREFIX = "logistics.push.touser.";
private static final java.util.regex.Pattern URL_DETECT_PATTERN = java.util.regex.Pattern.compile( private static final java.util.regex.Pattern URL_DETECT_PATTERN = java.util.regex.Pattern.compile(
"(https?://[^\\s]+)|(u\\.jd\\.com/[^\\s]+)", "(https?://[^\\s]+)|(u\\.jd\\.com/[^\\s]+)",
@@ -53,11 +55,13 @@ public class JDOrderController extends BaseController {
java.util.regex.Pattern.CASE_INSENSITIVE); java.util.regex.Pattern.CASE_INSENSITIVE);
public JDOrderController(IJDOrderService jdOrderService, IOrderRowsService orderRowsService, public JDOrderController(IJDOrderService jdOrderService, IOrderRowsService orderRowsService,
IGiftCouponService giftCouponService, ISysConfigService sysConfigService) { IGiftCouponService giftCouponService, ISysConfigService sysConfigService,
ILogisticsService logisticsService) {
this.jdOrderService = jdOrderService; this.jdOrderService = jdOrderService;
this.orderRowsService = orderRowsService; this.orderRowsService = orderRowsService;
this.giftCouponService = giftCouponService; this.giftCouponService = giftCouponService;
this.sysConfigService = sysConfigService; this.sysConfigService = sysConfigService;
this.logisticsService = logisticsService;
} }
private final static String skey = "2192057370ef8140c201079969c956a3"; private final static String skey = "2192057370ef8140c201079969c956a3";
@@ -68,12 +72,6 @@ public class JDOrderController extends BaseController {
@Value("${jarvis.server.jarvis-java.jd-api-path:/jd}") @Value("${jarvis.server.jarvis-java.jd-api-path:/jd}")
private String jdApiPath; private String jdApiPath;
@Value("${jarvis.server.logistics.base-url:http://127.0.0.1:5001}")
private String logisticsBaseUrl;
@Value("${jarvis.server.logistics.fetch-path:/fetch_logistics}")
private String logisticsFetchPath;
/** /**
* 获取JD接口请求URL * 获取JD接口请求URL
*/ */
@@ -947,9 +945,7 @@ public class JDOrderController extends BaseController {
logger.info("手动获取物流信息 - 订单ID: {}, 订单号: {}, 分销标识: {}, 物流链接: {}", logger.info("手动获取物流信息 - 订单ID: {}, 订单号: {}, 分销标识: {}, 物流链接: {}",
orderId, order.getOrderId(), distributionMark, logisticsLink); orderId, order.getOrderId(), distributionMark, logisticsLink);
// 构建外部接口URL String externalUrl = logisticsService.buildFetchLogisticsRequestUrl(logisticsLink);
String externalUrl = logisticsBaseUrl + logisticsFetchPath + "?tracking_url=" +
java.net.URLEncoder.encode(logisticsLink, "UTF-8");
logger.info("准备调用外部接口 - URL: {}", externalUrl); logger.info("准备调用外部接口 - URL: {}", externalUrl);
@@ -1057,22 +1053,30 @@ public class JDOrderController extends BaseController {
} }
try { try {
// 构建配置键名 String trimmed = distributionMark.trim();
String configKey = CONFIG_KEY_PREFIX + distributionMark.trim(); String configKey = CONFIG_KEY_PREFIX + trimmed;
// 从系统配置中获取接收人列表
String configValue = sysConfigService.selectConfigByKey(configKey); String configValue = sysConfigService.selectConfigByKey(configKey);
if (StringUtils.hasText(configValue)) { if (StringUtils.hasText(configValue)) {
// 清理配置值(去除空格)
String touser = configValue.trim().replaceAll(",\\s+", ","); String touser = configValue.trim().replaceAll(",\\s+", ",");
logger.info("从配置获取接收人列表 - 分销标识: {}, 配置键: {}, 接收人: {}", logger.info("从配置获取接收人列表 - 分销标识: {}, 配置键: {}, 接收人: {}",
distributionMark, configKey, touser); distributionMark, configKey, touser);
return touser; return touser;
} else { }
if (trimmed.startsWith("F-") || "F".equals(trimmed)) {
if (!trimmed.equals("F")) {
String fallbackKey = CONFIG_KEY_PREFIX + "F";
String fallbackVal = sysConfigService.selectConfigByKey(fallbackKey);
if (StringUtils.hasText(fallbackVal)) {
String touser = fallbackVal.trim().replaceAll(",\\s+", ",");
logger.info("从配置获取接收人列表F 系回退) - 分销标识: {}, 配置键: {}, 接收人: {}",
distributionMark, fallbackKey, touser);
return touser;
}
}
}
logger.debug("未找到接收人配置 - 分销标识: {}, 配置键: {}", distributionMark, configKey); logger.debug("未找到接收人配置 - 分销标识: {}, 配置键: {}", distributionMark, configKey);
return null; return null;
}
} catch (Exception e) { } catch (Exception e) {
logger.error("获取接收人配置失败 - 分销标识: {}, 错误: {}", distributionMark, e.getMessage(), e); logger.error("获取接收人配置失败 - 分销标识: {}, 错误: {}", distributionMark, e.getMessage(), e);
return null; return null;

View File

@@ -178,6 +178,8 @@ public class OrderRowsController extends BaseController
/** /**
* 构建统计数据。 * 构建统计数据。
* <p>汇总口径:{@code totalCommission} 为预估佣金合计,排除取消单(validCode=3){@code totalActualFee} 为实际佣金,仅统计已完成(validCode=17)
* {@code estimatePaidPending} 为已付款待收货(validCode=16)的预估佣金,与分组 {@code paid} 一致。</p>
* @param forList true=与列表同数据源(不排除 isCount=0保证总订单数与分页一致false=独立统计(排除 isCount=0 * @param forList true=与列表同数据源(不排除 isCount=0保证总订单数与分页一致false=独立统计(排除 isCount=0
*/ */
private Map<String, Object> buildStatistics(OrderRows orderRows, Date beginTime, Date endTime, boolean forList) { private Map<String, Object> buildStatistics(OrderRows orderRows, Date beginTime, Date endTime, boolean forList) {
@@ -209,7 +211,7 @@ public class OrderRowsController extends BaseController
groupStats.put("cancel", createGroupStat("取消", "cancel")); groupStats.put("cancel", createGroupStat("取消", "cancel"));
groupStats.put("invalid", createGroupStat("无效", "invalid")); groupStats.put("invalid", createGroupStat("无效", "invalid"));
groupStats.put("pending", createGroupStat("待付款", "pending")); groupStats.put("pending", createGroupStat("待付款", "pending"));
groupStats.put("paid", createGroupStat("已付款", "paid")); groupStats.put("paid", createGroupStat("已付款(待结算)", "paid"));
groupStats.put("finished", createGroupStat("已完成", "finished")); groupStats.put("finished", createGroupStat("已完成", "finished"));
groupStats.put("deposit", createGroupStat("已付定金", "deposit")); groupStats.put("deposit", createGroupStat("已付定金", "deposit"));
groupStats.put("illegal", createGroupStat("违规", "illegal")); groupStats.put("illegal", createGroupStat("违规", "illegal"));
@@ -263,8 +265,14 @@ public class OrderRowsController extends BaseController
actualFeeAmount = row.getActualFee() != null ? row.getActualFee() : 0; actualFeeAmount = row.getActualFee() != null ? row.getActualFee() : 0;
} }
// 顶部「预估佣金」汇总排除取消单validCode=3其余状态累加单条 commissionAmount
if (!"3".equals(validCode)) {
totalCommission += commissionAmount; totalCommission += commissionAmount;
}
// 顶部「实际佣金」汇总仅已完成validCode=17与联盟「已结算」口径一致
if ("17".equals(validCode)) {
totalActualFee += actualFeeAmount; totalActualFee += actualFeeAmount;
}
if (validCode != null) { if (validCode != null) {
for (Map.Entry<String, List<String>> group : groups.entrySet()) { for (Map.Entry<String, List<String>> group : groups.entrySet()) {
@@ -290,6 +298,8 @@ public class OrderRowsController extends BaseController
result.put("totalCosPrice", totalCosPrice); result.put("totalCosPrice", totalCosPrice);
result.put("totalCommission", totalCommission); result.put("totalCommission", totalCommission);
result.put("totalActualFee", totalActualFee); result.put("totalActualFee", totalActualFee);
// 已付款待结算:与分组 paid 的预估佣金口径一致,便于独立展示卡片
result.put("estimatePaidPending", (Double) groupStats.get("paid").get("commission"));
result.put("totalSkuNum", totalSkuNum); result.put("totalSkuNum", totalSkuNum);
result.put("violationOrders", violationOrders); result.put("violationOrders", violationOrders);
result.put("violationCommission", violationCommission); result.put("violationCommission", violationCommission);

View File

@@ -297,9 +297,7 @@ public class SocialMediaController extends BaseController
public AjaxResult generateXianyuWenan(@RequestBody Map<String, Object> request) public AjaxResult generateXianyuWenan(@RequestBody Map<String, Object> request)
{ {
try { try {
String title = (String) request.get("title"); Map<String, Object> result = socialMediaService.generateXianyuWenan(request);
String remark = (String) request.get("remark");
Map<String, Object> result = socialMediaService.generateXianyuWenan(title, remark);
if (Boolean.TRUE.equals(result.get("success"))) { if (Boolean.TRUE.equals(result.get("success"))) {
return AjaxResult.success(result); return AjaxResult.success(result);
} }

View File

@@ -55,6 +55,9 @@ public class TencentDocController extends BaseController {
@Autowired @Autowired
private com.ruoyi.jarvis.service.ITencentDocDelayedPushService delayedPushService; private com.ruoyi.jarvis.service.ITencentDocDelayedPushService delayedPushService;
@Autowired
private com.ruoyi.jarvis.wecom.WxSendGoofishNotifyClient wxSendGoofishNotifyClient;
/** 单次请求最大行数(腾讯文档 API行数≤1000 */ /** 单次请求最大行数(腾讯文档 API行数≤1000 */
private static final int API_MAX_ROWS_PER_REQUEST = 200; private static final int API_MAX_ROWS_PER_REQUEST = 200;
/** 用 rowTotal 时接口实际单次只能读 200 行 */ /** 用 rowTotal 时接口实际单次只能读 200 行 */
@@ -596,7 +599,8 @@ public class TencentDocController extends BaseController {
if (cell.containsKey("cellValue")) { if (cell.containsKey("cellValue")) {
String cellText = cell.getJSONObject("cellValue").getString("text"); String cellText = cell.getJSONObject("cellValue").getString("text");
if (cellText != null) { if (cellText != null) {
if (cellText.contains("单号")) { // 「物流单号」也含「单号」,须排除,否则会误把物流列当成单号列
if (cellText.contains("单号") && !cellText.contains("物流")) {
orderNoColumn = i; orderNoColumn = i;
} else if (cellText.contains("物流")) { } else if (cellText.contains("物流")) {
logisticsColumn = i; logisticsColumn = i;
@@ -608,7 +612,7 @@ public class TencentDocController extends BaseController {
if (orderNoColumn == -1 || logisticsColumn == -1) { if (orderNoColumn == -1 || logisticsColumn == -1) {
logOperation(null, fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, logOperation(null, fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink,
"FAILED", "未找到'单号'或'物流'列"); "FAILED", "未找到'单号'或'物流'列");
return AjaxResult.error("未找到'单号'或'物流'列,请检查表头配置"); return AjaxResult.error("未找到「单号/客户单号/第三方单号」或「物流列,请检查表头配置");
} }
log.info("表头解析完成 - 单号列: {}, 物流列: {}", orderNoColumn, logisticsColumn); log.info("表头解析完成 - 单号列: {}, 物流列: {}", orderNoColumn, logisticsColumn);
@@ -896,6 +900,56 @@ public class TencentDocController extends BaseController {
return null; return null;
} }
/**
* 批量同步中出现报错时将摘要推送到企业微信wxSend 闲鱼应用通道)
*/
private void pushTencentDocRowErrorsToWeCom(String batchId, String fileId, String sheetId,
int filledCount, int skippedCount, int errorCount,
List<Map<String, Object>> errorLogs) {
if (errorCount <= 0 && (errorLogs == null || errorLogs.isEmpty())) {
return;
}
StringBuilder sb = new StringBuilder();
sb.append("【腾讯文档推送】同步存在报错\n");
if (batchId != null && !batchId.isEmpty()) {
sb.append("批次: ").append(batchId).append("\n");
}
if (fileId != null && !fileId.isEmpty()) {
sb.append("fileId: ").append(fileId).append("\n");
}
if (sheetId != null && !sheetId.isEmpty()) {
sb.append("sheetId: ").append(sheetId).append("\n");
}
sb.append(String.format("成功队列: %d, 跳过: %d, 错误: %d\n", filledCount, skippedCount, errorCount));
if (errorLogs != null && !errorLogs.isEmpty()) {
int max = Math.min(15, errorLogs.size());
for (int i = 0; i < max; i++) {
Map<String, Object> el = errorLogs.get(i);
Object on = el != null ? el.get("orderNo") : null;
Object row = el != null ? el.get("row") : null;
Object em = el != null ? el.get("errorMessage") : null;
Object et = el != null ? el.get("errorType") : null;
sb.append(String.format("%d. 单号:%s 行:%s", i + 1,
on != null ? on : "-", row != null ? row : "-"));
if (et != null && String.valueOf(et).length() > 0) {
sb.append(" ").append(et);
}
sb.append("\n");
if (em != null) {
String msg = String.valueOf(em);
if (msg.length() > 120) {
msg = msg.substring(0, 119) + "";
}
sb.append(" ").append(msg).append("\n");
}
}
if (errorLogs.size() > max) {
sb.append("… 共 ").append(errorLogs.size()).append(" 条错误\n");
}
}
wxSendGoofishNotifyClient.pushGoofishAgentText(null, "", sb.toString());
}
/** /**
* 合并同一行的腾讯文档填充任务(物流更新与仅补京东单号合并为一次 batchUpdate * 合并同一行的腾讯文档填充任务(物流更新与仅补京东单号合并为一次 batchUpdate
*/ */
@@ -938,6 +992,7 @@ public class TencentDocController extends BaseController {
@Anonymous @Anonymous
@PostMapping("/fillLogisticsByOrderNo") @PostMapping("/fillLogisticsByOrderNo")
public AjaxResult fillLogisticsByOrderNo(@RequestBody Map<String, Object> params) { public AjaxResult fillLogisticsByOrderNo(@RequestBody Map<String, Object> params) {
String batchId = null;
try { try {
// 直接尝试刷新token如果失败说明需要首次授权 // 直接尝试刷新token如果失败说明需要首次授权
String accessToken; String accessToken;
@@ -955,7 +1010,7 @@ public class TencentDocController extends BaseController {
} }
// 从参数获取批次ID如果是批量调用会传入 // 从参数获取批次ID如果是批量调用会传入
String batchId = params.get("batchId") != null ? String.valueOf(params.get("batchId")) : null; batchId = params.get("batchId") != null ? String.valueOf(params.get("batchId")) : null;
// 从参数或配置中获取文档信息 // 从参数或配置中获取文档信息
String fileId = (String) params.get("fileId"); String fileId = (String) params.get("fileId");
@@ -1056,7 +1111,7 @@ public class TencentDocController extends BaseController {
return AjaxResult.error("无法识别表头,表头数据为空"); return AjaxResult.error("无法识别表头,表头数据为空");
} }
// 列名须与表格完全一致(仅忽略首尾空白、不间断空格等):备注、是否安排、物流单号、下单电话、标记、京东下单订单号;另需「单号」或「第三方单号」 // 列名须与表格完全一致(仅忽略首尾空白、不间断空格等):备注、是否安排、物流单号、下单电话、标记、京东下单订单号;另需「单号」「客户单号」或「第三方单号」之一
log.info("开始识别表头列(完全匹配列名),共 {} 列", headerRowData.size()); log.info("开始识别表头列(完全匹配列名),共 {} 列", headerRowData.size());
for (int i = 0; i < headerRowData.size(); i++) { for (int i = 0; i < headerRowData.size(); i++) {
String cellValue = headerRowData.getString(i); String cellValue = headerRowData.getString(i);
@@ -1074,6 +1129,10 @@ public class TencentDocController extends BaseController {
orderNoColumn = i; orderNoColumn = i;
log.info("✓ 列名完全匹配「单号」:第 {} 列(索引{}", i + 1, i); log.info("✓ 列名完全匹配「单号」:第 {} 列(索引{}", i + 1, i);
} }
if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellValue, "客户单号")) {
orderNoColumn = i;
log.info("\u2713 列名完全匹配「客户单号」:第 {} 列(索引{}", i + 1, i);
}
if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellValue, "第三方单号")) { if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellValue, "第三方单号")) {
orderNoColumn = i; orderNoColumn = i;
log.info("✓ 列名完全匹配「第三方单号」:第 {} 列(索引{}", i + 1, i); log.info("✓ 列名完全匹配「第三方单号」:第 {} 列(索引{}", i + 1, i);
@@ -1107,7 +1166,7 @@ public class TencentDocController extends BaseController {
// 检查必需的列是否都已识别 // 检查必需的列是否都已识别
if (orderNoColumn == null) { if (orderNoColumn == null) {
return AjaxResult.error("无法找到列名完全为「单号」或「第三方单号」的列(请与表格列名一致,勿加空格或后缀)"); return AjaxResult.error("无法找到列名完全为「单号」「客户单号」或「第三方单号」的列(请与表格列名一致,勿加空格或后缀)");
} }
if (logisticsLinkColumn == null) { if (logisticsLinkColumn == null) {
return AjaxResult.error("无法找到列名完全为「物流单号」的列(兼容「物流链接」);请与表格列名一致"); return AjaxResult.error("无法找到列名完全为「物流单号」的列(兼容「物流链接」);请与表格列名一致");
@@ -1400,7 +1459,7 @@ public class TencentDocController extends BaseController {
} }
try { try {
// 根据第三方单号查询订单 // 根据第三方单号查询订单(与文档「单号/客户单号/第三方单号」列单元格一致)
JDOrder order = jdOrderService.selectJDOrderByThirdPartyOrderNo(orderNo); JDOrder order = jdOrderService.selectJDOrderByThirdPartyOrderNo(orderNo);
if (order == null) { if (order == null) {
@@ -1920,7 +1979,7 @@ public class TencentDocController extends BaseController {
if (errorCount > 0 && successUpdates == 0) { if (errorCount > 0 && successUpdates == 0) {
status = "FAILED"; status = "FAILED";
} else if (errorCount > 0) { } else if (errorCount > 0) {
status = "PARTIAL_SUCCESS"; status = "PARTIAL";
} }
batchPushService.updateBatchPushRecord(batchId, status, successUpdates, skippedCount, errorCount, batchPushService.updateBatchPushRecord(batchId, status, successUpdates, skippedCount, errorCount,
message, null); message, null);
@@ -1948,7 +2007,21 @@ public class TencentDocController extends BaseController {
return AjaxResult.success("填充物流链接完成", result); return AjaxResult.success("填充物流链接完成", result);
} catch (Exception e) { } catch (Exception e) {
log.error("填充物流链接失败", e); log.error("填充物流链接失败", e);
return AjaxResult.error("填充物流链接失败: " + e.getMessage()); String errMsg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
if (batchId != null && !batchId.trim().isEmpty()) {
try {
batchPushService.updateBatchPushRecord(batchId, "FAILED", 0, 0, 0, null, errMsg);
} catch (Exception ex) {
log.error("异常后更新批量推送记录失败 batchId={}", batchId, ex);
}
try {
wxSendGoofishNotifyClient.pushGoofishAgentText(null, "",
"【腾讯文档推送】批量同步异常\n批次: " + batchId + "\n" + errMsg);
} catch (Exception ex) {
log.warn("腾讯文档推送异常企微通知失败: {}", ex.toString());
}
}
return AjaxResult.error("填充物流链接失败: " + errMsg);
} }
} }
@@ -2093,6 +2166,10 @@ public class TencentDocController extends BaseController {
String fileId = tencentDocConfig.getFileId(); String fileId = tencentDocConfig.getFileId();
String sheetId = tencentDocConfig.getSheetId(); String sheetId = tencentDocConfig.getSheetId();
if (fileId != null && !fileId.trim().isEmpty()) {
batchPushService.reconcileStaleRunningRecords(fileId);
}
if (fileId != null && sheetId != null) { if (fileId != null && sheetId != null) {
com.ruoyi.jarvis.domain.TencentDocBatchPushRecord lastSuccess = com.ruoyi.jarvis.domain.TencentDocBatchPushRecord lastSuccess =
batchPushService.getLastSuccessRecord(fileId, sheetId); batchPushService.getLastSuccessRecord(fileId, sheetId);
@@ -2239,6 +2316,10 @@ public class TencentDocController extends BaseController {
orderNoColumn = i; orderNoColumn = i;
log.info("✓ 列名完全匹配「单号」:第 {} 列(索引{}", i + 1, i); log.info("✓ 列名完全匹配「单号」:第 {} 列(索引{}", i + 1, i);
} }
if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellValue, "客户单号")) {
orderNoColumn = i;
log.info("\u2713 列名完全匹配「客户单号」:第 {} 列(索引{}", i + 1, i);
}
if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellValue, "第三方单号")) { if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellValue, "第三方单号")) {
orderNoColumn = i; orderNoColumn = i;
log.info("✓ 列名完全匹配「第三方单号」:第 {} 列(索引{}", i + 1, i); log.info("✓ 列名完全匹配「第三方单号」:第 {} 列(索引{}", i + 1, i);
@@ -2254,7 +2335,7 @@ public class TencentDocController extends BaseController {
} }
if (orderNoColumn == null || logisticsLinkColumn == null) { if (orderNoColumn == null || logisticsLinkColumn == null) {
return AjaxResult.error("无法识别表头列,请确保存在列名完全为「单号」或「第三方单号」,以及「物流单号」(或「物流链接」)"); return AjaxResult.error("无法识别表头列,请确保存在列名完全为「单号」「客户单号」或「第三方单号」,以及「物流单号」(或「物流链接」)");
} }
// 统计结果 // 统计结果
@@ -2543,6 +2624,10 @@ public class TencentDocController extends BaseController {
orderNoColumn = i; orderNoColumn = i;
log.info("✓ 列名完全匹配「单号」:第 {} 列(索引{}", i + 1, i); log.info("✓ 列名完全匹配「单号」:第 {} 列(索引{}", i + 1, i);
} }
if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellValue, "客户单号")) {
orderNoColumn = i;
log.info("\u2713 列名完全匹配「客户单号」:第 {} 列(索引{}", i + 1, i);
}
if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellValue, "第三方单号")) { if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellValue, "第三方单号")) {
orderNoColumn = i; orderNoColumn = i;
log.info("✓ 列名完全匹配「第三方单号」:第 {} 列(索引{}", i + 1, i); log.info("✓ 列名完全匹配「第三方单号」:第 {} 列(索引{}", i + 1, i);
@@ -2558,7 +2643,7 @@ public class TencentDocController extends BaseController {
} }
if (orderNoColumn == null || logisticsLinkColumn == null) { if (orderNoColumn == null || logisticsLinkColumn == null) {
return AjaxResult.error("无法识别表头列,请确保存在列名完全为「单号」或「第三方单号」,以及「物流单号」(或「物流链接」)"); return AjaxResult.error("无法识别表头列,请确保存在列名完全为「单号」「客户单号」或「第三方单号」,以及「物流单号」(或「物流链接」)");
} }
// 统计结果 // 统计结果
@@ -2650,11 +2735,10 @@ public class TencentDocController extends BaseController {
String cleanedLogisticsLink = cleanLogisticsLink(logisticsLinkFromDoc); String cleanedLogisticsLink = cleanLogisticsLink(logisticsLinkFromDoc);
try { try {
// 通过第三方单号查找本地订单 // 通过第三方单号查找本地订单找不到再按内部单号remark
JDOrder order = jdOrderService.selectJDOrderByThirdPartyOrderNo(orderNoFromDoc.trim()); JDOrder order = jdOrderService.selectJDOrderByThirdPartyOrderNo(orderNoFromDoc.trim());
if (order == null) { if (order == null) {
// 如果通过第三方单号找不到尝试通过内部单号remark查找
order = jdOrderService.selectJDOrderByRemark(orderNoFromDoc.trim()); order = jdOrderService.selectJDOrderByRemark(orderNoFromDoc.trim());
} }
@@ -3080,6 +3164,14 @@ public class TencentDocController extends BaseController {
} }
} }
if (errorCount > 0 || (errorLogs != null && !errorLogs.isEmpty())) {
try {
pushTencentDocRowErrorsToWeCom(batchId, fileId, sheetId, filledCount, skippedCount, errorCount, errorLogs);
} catch (Exception ex) {
log.warn("腾讯文档报错企微推送失败: {}", ex.toString());
}
}
// 构建请求体 // 构建请求体
JSONObject requestBody = new JSONObject(); JSONObject requestBody = new JSONObject();
requestBody.put("title", "腾讯文档同步成功"); requestBody.put("title", "腾讯文档同步成功");

View File

@@ -9,6 +9,7 @@ import com.ruoyi.jarvis.service.ILogisticsService;
import com.ruoyi.jarvis.service.IWeComShareLinkLogisticsJobService; import com.ruoyi.jarvis.service.IWeComShareLinkLogisticsJobService;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -70,6 +71,9 @@ public class WeComShareLinkLogisticsJobController extends BaseController {
if (job == null) { if (job == null) {
return AjaxResult.error("任务不存在"); return AjaxResult.error("任务不存在");
} }
if ("CANCELLED".equalsIgnoreCase(job.getStatus())) {
return AjaxResult.error("任务已取消扫描,请先恢复或新建任务");
}
if (!StringUtils.hasText(job.getTrackingUrl())) { if (!StringUtils.hasText(job.getTrackingUrl())) {
return AjaxResult.error("该任务无物流短链"); return AjaxResult.error("该任务无物流短链");
} }
@@ -109,4 +113,49 @@ public class WeComShareLinkLogisticsJobController extends BaseController {
r.put("hint", "为单次弹栈处理条数;每项内部仍可能因未出单重新入队"); r.put("hint", "为单次弹栈处理条数;每项内部仍可能因未出单重新入队");
return AjaxResult.success(r); return AjaxResult.success(r);
} }
/**
* 订单取消等:标记为 CANCELLED不再被定时对账入队队列弹出时也会跳过物流请求与推送。
*/
@PreAuthorize("@ss.hasPermi('jarvis:wecom:shareLinkLog:list')")
@PostMapping("/cancel")
public AjaxResult cancel(@RequestBody Map<String, Object> body) {
if (body == null || body.get("jobKey") == null) {
return AjaxResult.error("jobKey 不能为空");
}
String jobKey = body.get("jobKey").toString().trim();
if (!StringUtils.hasText(jobKey)) {
return AjaxResult.error("jobKey 不能为空");
}
WeComShareLinkLogisticsJob job = weComShareLinkLogisticsJobService.selectByJobKey(jobKey);
if (job == null) {
return AjaxResult.error("任务不存在");
}
if ("CANCELLED".equalsIgnoreCase(job.getStatus())) {
return AjaxResult.success("已是取消状态");
}
String extra = body.get("lastNote") != null ? body.get("lastNote").toString().trim() : "";
String note = "manual_cancel";
if (StringUtils.hasText(extra)) {
note = note + "|" + extra;
}
if (note.length() > 500) {
note = note.substring(0, 500) + "";
}
weComShareLinkLogisticsJobMapper.updateByJobKey(jobKey, "CANCELLED", note, null, null);
return AjaxResult.success();
}
/**
* 物理删除任务行Redis 中已存在的同 jobKey 队列项仍可能被弹出,但会因库中无行而跳过扫描)。
*/
@PreAuthorize("@ss.hasPermi('jarvis:wecom:shareLinkLog:list')")
@DeleteMapping("/{jobKey}")
public AjaxResult remove(@PathVariable("jobKey") String jobKey) {
if (!StringUtils.hasText(jobKey)) {
return AjaxResult.error("jobKey 不能为空");
}
weComShareLinkLogisticsJobMapper.deleteByJobKey(jobKey.trim());
return AjaxResult.success();
}
} }

View File

@@ -5,6 +5,7 @@ import com.alibaba.fastjson2.JSONObject;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
@@ -12,6 +13,7 @@ import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.framework.web.domain.Server; import com.ruoyi.framework.web.domain.Server;
import com.ruoyi.jarvis.service.ILogisticsService; import com.ruoyi.jarvis.service.ILogisticsService;
import com.ruoyi.jarvis.service.IWxSendService; import com.ruoyi.jarvis.service.IWxSendService;
import com.ruoyi.jarvis.wecom.WxSendGoofishNotifyClient;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.HashMap; import java.util.HashMap;
@@ -32,6 +34,9 @@ public class ServerController
@Resource @Resource
private IWxSendService wxSendService; private IWxSendService wxSendService;
@Resource
private WxSendGoofishNotifyClient wxSendGoofishNotifyClient;
/** Ollama 服务地址,用于健康检查 */ /** Ollama 服务地址,用于健康检查 */
@Value("${jarvis.ollama.base-url:http://192.168.8.34:11434}") @Value("${jarvis.ollama.base-url:http://192.168.8.34:11434}")
private String ollamaBaseUrl; private String ollamaBaseUrl;
@@ -72,23 +77,23 @@ public class ServerController
healthMap.put("logistics", logisticsMap); healthMap.put("logistics", logisticsMap);
} }
// 微信推送服务健康检测 // 微信推送:不在此自动下发消息,仅展示配置地址;真实检测见 POST /monitor/server/health/wx-send-test
try {
IWxSendService.HealthCheckResult wxSendHealth = wxSendService.checkHealth();
Map<String, Object> wxSendMap = new HashMap<>(); Map<String, Object> wxSendMap = new HashMap<>();
wxSendMap.put("healthy", wxSendHealth.isHealthy()); wxSendMap.put("manualOnly", true);
wxSendMap.put("status", wxSendHealth.getStatus()); wxSendMap.put("healthy", null);
wxSendMap.put("message", wxSendHealth.getMessage()); wxSendMap.put("status", "未检测");
wxSendMap.put("serviceUrl", wxSendHealth.getServiceUrl()); wxSendMap.put("message", "点击「测试」将发送一条健康检查消息(会真实推送到微信)");
wxSendMap.put("serviceUrl", wxSendService.getHealthCheckServiceUrl());
healthMap.put("wxSend", wxSendMap); healthMap.put("wxSend", wxSendMap);
} catch (Exception e) {
Map<String, Object> wxSendMap = new HashMap<>(); // 企微闲鱼通知:仅展示接口地址;真实检测见 POST /monitor/server/health/goofish-notify-test
wxSendMap.put("healthy", false); Map<String, Object> goofishMap = new HashMap<>();
wxSendMap.put("status", "异常"); goofishMap.put("manualOnly", true);
wxSendMap.put("message", "健康检测异常: " + e.getMessage()); goofishMap.put("healthy", null);
wxSendMap.put("serviceUrl", ""); goofishMap.put("status", "未检测");
healthMap.put("wxSend", wxSendMap); goofishMap.put("message", "点击「测试」将经 wxSend 向企微闲鱼应用发送一条测试文本");
} goofishMap.put("serviceUrl", wxSendGoofishNotifyClient.getGoofishPushEndpointDisplay());
healthMap.put("goofishNotify", goofishMap);
// Ollama 服务健康检测(调试用) // Ollama 服务健康检测(调试用)
try { try {
@@ -116,6 +121,54 @@ public class ServerController
return AjaxResult.success(healthMap); return AjaxResult.success(healthMap);
} }
/**
* 手动测试微信推送(会真实下发一条消息)
*/
@PreAuthorize("@ss.hasPermi('monitor:server:list')")
@PostMapping("/health/wx-send-test")
public AjaxResult testWxSendHealth() {
try {
IWxSendService.HealthCheckResult r = wxSendService.checkHealth();
Map<String, Object> m = new HashMap<>();
m.put("manualOnly", true);
m.put("healthy", r.isHealthy());
m.put("status", r.getStatus());
m.put("message", r.getMessage());
m.put("serviceUrl", r.getServiceUrl());
return AjaxResult.success(m);
} catch (Exception e) {
Map<String, Object> m = new HashMap<>();
m.put("manualOnly", true);
m.put("healthy", false);
m.put("status", "异常");
m.put("message", "检测异常: " + e.getMessage());
m.put("serviceUrl", wxSendService.getHealthCheckServiceUrl());
return AjaxResult.success(m);
}
}
/**
* 手动测试企微闲鱼通知(经 wxSend POST /wx/send/goofish与 /send/pdd 相同 vanToken + title/text/touser
*/
@PreAuthorize("@ss.hasPermi('monitor:server:list')")
@PostMapping("/health/goofish-notify-test")
public AjaxResult testGoofishNotify() {
String err = wxSendGoofishNotifyClient.testGoofishNotify();
Map<String, Object> m = new HashMap<>();
m.put("manualOnly", true);
m.put("serviceUrl", wxSendGoofishNotifyClient.getGoofishPushEndpointDisplay());
if (err == null) {
m.put("healthy", true);
m.put("status", "正常");
m.put("message", "闲鱼通知测试消息已发送");
} else {
m.put("healthy", false);
m.put("status", "异常");
m.put("message", err);
}
return AjaxResult.success(m);
}
private void putOllamaUnhealthy(Map<String, Object> healthMap, String url, String message) { private void putOllamaUnhealthy(Map<String, Object> healthMap, String url, String message) {
Map<String, Object> ollamaMap = new HashMap<>(); Map<String, Object> ollamaMap = new HashMap<>();
ollamaMap.put("healthy", false); ollamaMap.put("healthy", false);

View File

@@ -2,6 +2,8 @@ package com.ruoyi.web.controller.system;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@@ -24,6 +26,7 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.dto.JDOrderSimpleDTO; import com.ruoyi.jarvis.domain.dto.JDOrderSimpleDTO;
import com.ruoyi.jarvis.domain.dto.QuickRecordModelOption;
import com.ruoyi.jarvis.service.IJDOrderProfitService; import com.ruoyi.jarvis.service.IJDOrderProfitService;
import com.ruoyi.jarvis.service.IJDOrderService; import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.IInstructionService; import com.ruoyi.jarvis.service.IInstructionService;
@@ -101,6 +104,9 @@ public class JDOrderListController extends BaseController
if ("true".equalsIgnoreCase(request.getParameter("hasRebateRemark"))) { if ("true".equalsIgnoreCase(request.getParameter("hasRebateRemark"))) {
query.getParams().put("hasRebateRemark", true); query.getParams().put("hasRebateRemark", true);
} }
if ("true".equalsIgnoreCase(request.getParameter("rebateWithoutUploadLink"))) {
query.getParams().put("rebateWithoutUploadLink", true);
}
java.util.List<JDOrder> list; java.util.List<JDOrder> list;
if (orderBy != null && !orderBy.isEmpty()) { if (orderBy != null && !orderBy.isEmpty()) {
@@ -152,6 +158,15 @@ public class JDOrderListController extends BaseController
return dataTable; return dataTable;
} }
/**
* 快捷录单页:型号下拉数据;每型号取 jd_order 主键最大的一条的付款与后返(通常即最近落库单)
*/
@GetMapping("/quickRecord/modelOptions")
public AjaxResult quickRecordModelOptions() {
List<QuickRecordModelOption> options = jdOrderService.selectQuickRecordModelOptions();
return AjaxResult.success(options);
}
/** /**
* 导入跟团返现类 Excel按「单号/订单号」匹配系统订单,将「是否返现」「总共返现」等写入后返备注(可多次导入累加);文件落盘并记上传记录。 * 导入跟团返现类 Excel按「单号/订单号」匹配系统订单,将「是否返现」「总共返现」等写入后返备注(可多次导入累加);文件落盘并记上传记录。
*/ */
@@ -376,9 +391,38 @@ public class JDOrderListController extends BaseController
} }
/** /**
* 一次性批量更新历史订单将赔付金额大于0的订单标记为后返到账 * 列表刷新后:对利润未手动的订单按规则重算,仅结果变化时落库(不解除售价/利润锁定)
* 此方法只应执行一次,用于处理历史数据
*/ */
@Log(title = "JD订单同步自动利润", businessType = BusinessType.UPDATE)
@PostMapping("/tools/sync-auto-profit")
@SuppressWarnings("unchecked")
public AjaxResult syncAutoProfit(@RequestBody(required = false) Map<String, Object> body) {
if (body == null || !body.containsKey("ids")) {
return AjaxResult.error("请传入 ids 数组");
}
Object raw = body.get("ids");
if (!(raw instanceof List)) {
return AjaxResult.error("ids 须为数组");
}
List<?> idList = (List<?>) raw;
if (idList.isEmpty()) {
Map<String, Object> empty = new HashMap<>(2);
empty.put("updated", 0);
return AjaxResult.success(empty);
}
List<Long> ids = new ArrayList<>(idList.size());
for (Object o : idList) {
if (o == null) {
continue;
}
ids.add(((Number) o).longValue());
}
int n = jdOrderProfitService.syncAutoProfitIfChanged(ids);
Map<String, Object> data = new HashMap<>(2);
data.put("updated", n);
return AjaxResult.success(data);
}
/** /**
* 按 ID 批量重算售价(自动从型号配置回填)与利润(清除手动锁定后按规则计算) * 按 ID 批量重算售价(自动从型号配置回填)与利润(清除手动锁定后按规则计算)
*/ */
@@ -457,6 +501,17 @@ public class JDOrderListController extends BaseController
query.getParams().put("orderSearch", orderSearch.trim()); query.getParams().put("orderSearch", orderSearch.trim());
} }
String rebateRemarkAbnormal = request.getParameter("rebateRemarkHasAbnormal");
if (rebateRemarkAbnormal != null && !rebateRemarkAbnormal.isEmpty()) {
query.setRebateRemarkHasAbnormal(Integer.valueOf(rebateRemarkAbnormal));
}
if ("true".equalsIgnoreCase(request.getParameter("hasRebateRemark"))) {
query.getParams().put("hasRebateRemark", true);
}
if ("true".equalsIgnoreCase(request.getParameter("rebateWithoutUploadLink"))) {
query.getParams().put("rebateWithoutUploadLink", true);
}
// 处理其他查询参数 // 处理其他查询参数
if (query.getRemark() != null && !query.getRemark().trim().isEmpty()) { if (query.getRemark() != null && !query.getRemark().trim().isEmpty()) {
query.setRemark(query.getRemark().trim()); query.setRemark(query.getRemark().trim());
@@ -467,6 +522,9 @@ public class JDOrderListController extends BaseController
if (query.getModelNumber() != null && !query.getModelNumber().trim().isEmpty()) { if (query.getModelNumber() != null && !query.getModelNumber().trim().isEmpty()) {
query.setModelNumber(query.getModelNumber().trim()); query.setModelNumber(query.getModelNumber().trim());
} }
if (query.getModelNumberExclude() != null && !query.getModelNumberExclude().trim().isEmpty()) {
query.setModelNumberExclude(query.getModelNumberExclude().trim());
}
if (query.getBuyer() != null && !query.getBuyer().trim().isEmpty()) { if (query.getBuyer() != null && !query.getBuyer().trim().isEmpty()) {
query.setBuyer(query.getBuyer().trim()); query.setBuyer(query.getBuyer().trim());
} }

View File

@@ -200,10 +200,18 @@ jarvis:
# 物流接口服务地址 # 物流接口服务地址
logistics: logistics:
base-url: http://192.168.8.88:5001 base-url: http://192.168.8.88:5001
# 同机多进程多端口时配置逗号分隔列表;非空时仅按下列地址轮询,不再使用 base-url
base-urls: http://192.168.8.88:5001,http://192.168.8.88:5002,http://192.168.8.88:5003
fetch-path: /fetch_logistics fetch-path: /fetch_logistics
health-path: /health health-path: /health
# 每次定时任务最多处理多少条企微分享链待队列RPUSH 入队、LPOP 出队) # 每次定时任务最多处理多少条企微分享链待队列RPUSH 入队、LPOP 出队)
adhoc-pending-batch-size: 50 adhoc-pending-batch-size: 50
# 物流扫描LogisticsScanTask轮询 JD 单拉运单 + drain 分享链队列
scan:
cron: "0 */20 * * * ?"
order-delay-ms: 250
# 0=不限制;例如 40 可控制单轮最长耗时(余下下轮再扫)
max-orders-per-round: 0
# 获取评论接口服务地址(后端转发,避免前端跨域) # 获取评论接口服务地址(后端转发,避免前端跨域)
fetch-comments: fetch-comments:
base-url: http://192.168.8.60:5008 base-url: http://192.168.8.60:5008
@@ -214,11 +222,15 @@ jarvis:
wxsend-base-url: http://127.0.0.1:36699 wxsend-base-url: http://127.0.0.1:36699
# 须与 wxSend jarvis.wecom.push-secret 一致Header X-WxSend-WeCom-Push-Secret # 须与 wxSend jarvis.wecom.push-secret 一致Header X-WxSend-WeCom-Push-Secret
push-secret: jarvis_wecom_push_change_me push-secret: jarvis_wecom_push_change_me
# 与 /wx/send/pdd、/wx/send/goofish 请求头 vanToken 一致wxSend TokenUtil
wxsend-van-token: super_token_b62190c26
# 接收企微通知的成员 UserID多个逗号或 |;留空则不推送
goofish-notify-touser: "LinPinFan"
# 多轮会话:与 JDUtil interaction_state 类似TTL 与空闲超时(分钟) # 多轮会话:与 JDUtil interaction_state 类似TTL 与空闲超时(分钟)
session-ttl-minutes: 30 session-ttl-minutes: 30
session-idle-timeout-minutes: 30 session-idle-timeout-minutes: 30
session-sweep-ms: 60000 session-sweep-ms: 60000
# 企微「开」+ 手机号:Jarvis POST 该局域网接口,将响应中的 reply_text 被动回复用户 # 企微「开」/「慢开」+ 手机号:POST body 含 text手机号与 bot响应 reply_text 被动回复用户
phone-forward: phone-forward:
enabled: true enabled: true
base-url: http://192.168.8.60:18080 base-url: http://192.168.8.60:18080
@@ -227,7 +239,12 @@ jarvis:
# wait_reply 时服务端会等多条 Bot 回复,宜适当加大 # wait_reply 时服务端会等多条 Bot 回复,宜适当加大
read-timeout-ms: 120000 read-timeout-ms: 120000
wait-reply: true wait-reply: true
reply-take-nth: 2 # 多台企微线程同时触发时串行调用 tg_bridge排队超过该毫秒则提示「正忙」0 表示一直等到上一条结束)
lock-acquire-timeout-ms: 180000
# 连续失败后熔断,不再发起 HTTP与 tg_bridge 侧熔断互不替代)
circuit-failure-threshold: 5
circuit-open-ms: 120000
# reply_take_nth仅「开」用 2「慢开」由 tg_bridge reply_adaptive_skip_middle_ad 在 2/3 条间自适应
# Ollama 大模型服务(监控健康度调试用) # Ollama 大模型服务(监控健康度调试用)
ollama: ollama:
base-url: http://192.168.8.34:11434 base-url: http://192.168.8.34:11434
@@ -254,5 +271,32 @@ tencent:
# 刷新Token地址用于通过refresh_token刷新access_token # 刷新Token地址用于通过refresh_token刷新access_token
refresh-token-url: https://docs.qq.com/oauth/v2/token refresh-token-url: https://docs.qq.com/oauth/v2/token
# 闲管家订单RocketMQ配置后订单推送走 MQ不配则走线程池异步
#rocketmq:
# name-server: 127.0.0.1:9876
# producer:
# group: jarvis-goofish-producer
# send-message-timeout: 3000
jarvis:
goofish-order:
mq-topic: jarvis-goofish-erp-order
consumer-group: jarvis-goofish-order-consumer
pull-lookback-hours: 72
pull-cron: "0 * * * * ?"
auto-ship-cron: "0 2/10 * * * ?"
# 订单列表:每页条数(最大 100
pull-page-size: 100
# 每授权单次最大页数(最大 100与 page_size 乘积勿超 10000
pull-max-pages-per-shop: 100
# 全量拉单按 update_time 分段(秒),默认 7 天(且不超过 pull-max-update-time-range-seconds
pull-time-chunk-seconds: 604800
# 单次列表请求 update_time 最大跨度须满足平台「6个月内」默认 180 天
pull-max-update-time-range-seconds: 15552000
# 全量拉单起点:距今多少天(默认约 3 年)
pull-full-history-days: 1095
# true=仅拉 auto-ship-order-statuses省调用其它状态依赖推送false=时间窗内全状态(推荐,与本地对齐)
pull-list-only-auto-ship-statuses: false
auto-ship-batch-size: 20

View File

@@ -200,16 +200,24 @@ jarvis:
# 物流接口服务地址 # 物流接口服务地址
logistics: logistics:
base-url: http://127.0.0.1:5001 base-url: http://127.0.0.1:5001
# 同机多进程多端口时配置逗号分隔列表;非空时仅按下列地址轮询,不再使用 base-url
base-urls: http://127.0.0.1:5001,http://127.0.0.1:5002,http://127.0.0.1:5003
fetch-path: /fetch_logistics fetch-path: /fetch_logistics
health-path: /health health-path: /health
adhoc-pending-batch-size: 50 adhoc-pending-batch-size: 50
scan:
cron: "0 */20 * * * ?"
order-delay-ms: 250
max-orders-per-round: 0
# 获取评论接口服务地址(后端转发) # 获取评论接口服务地址(后端转发)
fetch-comments: fetch-comments:
base-url: http://192.168.8.60:5008 base-url: http://192.168.8.60:5008
wecom: wecom:
inbound-secret: jarvis_wecom_bridge_change_me inbound-secret: jarvis_wecom_bridge_change_me
wxsend-base-url: http://127.0.0.1:36699 wxsend-base-url: https://wxts.van333.cn
push-secret: jarvis_wecom_push_change_me push-secret: jarvis_wecom_push_change_me
wxsend-van-token: super_token_b62190c26
goofish-notify-touser: "LinPingFan"
session-ttl-minutes: 30 session-ttl-minutes: 30
session-idle-timeout-minutes: 30 session-idle-timeout-minutes: 30
session-sweep-ms: 60000 session-sweep-ms: 60000
@@ -220,7 +228,10 @@ jarvis:
connect-timeout-ms: 8000 connect-timeout-ms: 8000
read-timeout-ms: 120000 read-timeout-ms: 120000
wait-reply: true wait-reply: true
reply-take-nth: 2 lock-acquire-timeout-ms: 180000
circuit-failure-threshold: 5
circuit-open-ms: 120000
# 「开」取第 2 条;「慢开」由桥接自适应第 2/3 条
# Ollama 大模型服务(监控健康度调试用) # Ollama 大模型服务(监控健康度调试用)
ollama: ollama:
base-url: http://192.168.8.34:11434 base-url: http://192.168.8.34:11434

View File

@@ -0,0 +1,114 @@
-- 闲管家开放平台:应用配置中心 + ERP 订单落库(执行前请备份)
-- 说明:菜单需在「系统管理-菜单管理」中自行新增,组件路径示例:
-- 配置中心 system/goofish/erpOpenConfig/index
-- 订单跟踪 system/goofish/erpGoofishOrder/index
-- 变更日志(跨单排查,对接 GET /jarvis/erpGoofishOrder/eventLog/listsystem/goofish/erpGoofishEventLog/index
CREATE TABLE IF NOT EXISTS erp_open_config (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
app_key varchar(64) NOT NULL COMMENT '开放平台 AppKey(appid)',
app_secret varchar(128) NOT NULL COMMENT '开放平台 AppSecret',
xy_user_name varchar(128) DEFAULT NULL COMMENT '默认闲鱼会员名(展示)',
express_code varchar(64) DEFAULT NULL COMMENT '发货用快递公司编码(日日顺物流在官方列表中为 rrs以「查询快递公司」接口为准',
express_name varchar(64) DEFAULT NULL COMMENT '快递公司名称(展示)',
status char(1) NOT NULL DEFAULT '0' COMMENT '0正常 1停用',
order_num int(11) NOT NULL DEFAULT 0 COMMENT '排序(小优先)',
create_by varchar(64) DEFAULT '',
create_time datetime DEFAULT NULL,
update_by varchar(64) DEFAULT '',
update_time datetime DEFAULT NULL,
remark varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (id),
UNIQUE KEY uk_app_key (app_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闲管家开放平台应用配置';
CREATE TABLE IF NOT EXISTS erp_goofish_order (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
app_key varchar(64) NOT NULL COMMENT 'AppKey',
seller_id bigint(20) DEFAULT NULL COMMENT '商家ID',
user_name varchar(128) DEFAULT NULL COMMENT '闲鱼会员名',
order_no varchar(64) NOT NULL COMMENT '闲鱼订单号',
order_type int(11) DEFAULT NULL COMMENT '订单类型',
order_status int(11) DEFAULT NULL COMMENT '订单状态(推送/列表)',
refund_status int(11) DEFAULT NULL COMMENT '退款状态',
modify_time bigint(20) DEFAULT NULL COMMENT '订单更新时间(秒)',
product_id bigint(20) DEFAULT NULL COMMENT '管家商品ID',
item_id bigint(20) DEFAULT NULL COMMENT '闲鱼商品ID',
goods_title varchar(512) DEFAULT NULL COMMENT '商品标题(详情 goods.title)',
goods_image_url varchar(1024) DEFAULT NULL COMMENT '商品主图 URL(goods.images 首图)',
buyer_nick varchar(256) DEFAULT NULL COMMENT '买家昵称(详情 buyer_nick)',
pay_amount bigint(20) DEFAULT NULL COMMENT '实付金额(分) pay_amount',
detail_waybill_no varchar(128) DEFAULT NULL COMMENT '闲管家详情回传运单号 waybill_no',
detail_express_code varchar(64) DEFAULT NULL COMMENT '详情快递编码 express_code',
detail_express_name varchar(128) DEFAULT NULL COMMENT '详情快递名称 express_name',
receiver_name varchar(128) DEFAULT NULL COMMENT '收货人(详情有则落库)',
receiver_mobile varchar(64) DEFAULT NULL COMMENT '收货手机',
receiver_address varchar(1000) DEFAULT NULL COMMENT '收货详细地址(address)',
receiver_region varchar(256) DEFAULT NULL COMMENT '省市区街道等拼接展示',
recv_prov_name varchar(64) DEFAULT NULL COMMENT 'prov_name',
recv_city_name varchar(64) DEFAULT NULL COMMENT 'city_name',
recv_area_name varchar(64) DEFAULT NULL COMMENT 'area_name',
recv_town_name varchar(128) DEFAULT NULL COMMENT 'town_name',
detail_json longtext COMMENT '订单详情接口全量 JSON',
last_notify_json longtext COMMENT '最近一次推送原文 JSON',
jd_order_id bigint(20) DEFAULT NULL COMMENT '关联 jd_order.id第三方单号=闲鱼单号)',
local_waybill_no varchar(128) DEFAULT NULL COMMENT '本地物流扫描得到的运单号',
ship_status tinyint(4) NOT NULL DEFAULT '0' COMMENT '0未发货 1已调用发货成功 2发货失败',
ship_error varchar(500) DEFAULT NULL COMMENT '发货失败原因',
ship_time datetime DEFAULT NULL COMMENT '发货调用成功时间',
ship_express_code varchar(64) DEFAULT NULL COMMENT '实际发货使用的快递编码',
create_time datetime DEFAULT NULL,
update_time datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_app_order (app_key, order_no),
KEY idx_jd_order (jd_order_id),
KEY idx_order_status (order_status),
KEY idx_modify_time (modify_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闲管家 ERP 订单(全量跟踪)';
CREATE TABLE IF NOT EXISTS erp_goofish_order_event_log (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
order_id bigint(20) NOT NULL COMMENT 'erp_goofish_order.id',
app_key varchar(64) DEFAULT NULL,
order_no varchar(64) NOT NULL,
event_type varchar(32) NOT NULL COMMENT 'ORDER_SYNC/LOGISTICS_SYNC/SHIP',
source varchar(64) NULL COMMENT 'NOTIFY/LIST/DETAIL_REFRESH 等',
message varchar(1024) NOT NULL,
create_time datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_goofish_evt_order (order_id),
KEY idx_goofish_evt_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闲管家订单状态/物流/发货变更日志';
-- 可选:从旧枚举迁入两条示例(密钥请上线后立即修改)
-- INSERT INTO erp_open_config (app_key,app_secret,xy_user_name,remark,express_code,express_name,status,order_num)
-- VALUES ('1016208368633221','***','余生请多关照66','海尔胡歌',NULL,'日日顺','0',1);
-- 若依菜单(在「菜单管理」手工添加时参考)
-- 父菜单系统管理下新增目录「闲管家ERP」
-- 子菜单1组件 system/goofish/erpOpenConfig/index 权限前缀 jarvis:erpOpenConfig
-- 子菜单2组件 system/goofish/erpGoofishOrder/index 权限前缀 jarvis:erpGoofishOrder
-- 子菜单3组件 system/goofish/erpGoofishEventLog/index 权限沿用 jarvis:erpGoofishOrder:list 即可(仅列表查询)
-- 路由地址建议与订单页同级,如订单为 …/erpGoofishOrder 则本页 …/erpGoofishEventLog订单页「变更日志排查」按钮依赖此规则
-- 按钮权限示例:
-- jarvis:erpOpenConfig:list,query,add,edit,remove
-- jarvis:erpGoofishOrder:list,query,edit
-- —— 已建表升级:详情摘要字段(若列已存在会报错,可逐条执行并忽略)——
-- ALTER TABLE erp_goofish_order ADD COLUMN goods_title varchar(512) NULL COMMENT '商品标题' AFTER item_id;
-- ALTER TABLE erp_goofish_order ADD COLUMN goods_image_url varchar(1024) NULL COMMENT '商品主图URL' AFTER goods_title;
-- ALTER TABLE erp_goofish_order ADD COLUMN buyer_nick varchar(256) NULL COMMENT '买家昵称' AFTER goods_image_url;
-- ALTER TABLE erp_goofish_order ADD COLUMN pay_amount bigint(20) NULL COMMENT '实付金额(分)' AFTER buyer_nick;
-- ALTER TABLE erp_goofish_order ADD COLUMN detail_waybill_no varchar(128) NULL COMMENT '详情运单号' AFTER pay_amount;
-- ALTER TABLE erp_goofish_order ADD COLUMN detail_express_code varchar(64) NULL COMMENT '详情快递编码' AFTER detail_waybill_no;
-- ALTER TABLE erp_goofish_order ADD COLUMN detail_express_name varchar(128) NULL COMMENT '详情快递名称' AFTER detail_express_code;
-- ALTER TABLE erp_goofish_order ADD COLUMN receiver_name varchar(128) NULL COMMENT '收货人' AFTER detail_express_name;
-- ALTER TABLE erp_goofish_order ADD COLUMN receiver_mobile varchar(64) NULL COMMENT '收货手机' AFTER receiver_name;
-- ALTER TABLE erp_goofish_order ADD COLUMN receiver_address varchar(1000) NULL COMMENT '收货地址' AFTER receiver_mobile;
-- ALTER TABLE erp_goofish_order ADD COLUMN receiver_region varchar(256) NULL COMMENT '省市区' AFTER receiver_address;
-- ALTER TABLE erp_goofish_order ADD COLUMN recv_prov_name varchar(64) NULL COMMENT 'prov_name' AFTER receiver_region;
-- ALTER TABLE erp_goofish_order ADD COLUMN recv_city_name varchar(64) NULL COMMENT 'city_name' AFTER recv_prov_name;
-- ALTER TABLE erp_goofish_order ADD COLUMN recv_area_name varchar(64) NULL COMMENT 'area_name' AFTER recv_city_name;
-- ALTER TABLE erp_goofish_order ADD COLUMN recv_town_name varchar(128) NULL COMMENT 'town_name' AFTER recv_area_name;
-- 已建库一键升级(可重复执行、自动判存):请使用同目录 erp_goofish_upgrade.sql

View File

@@ -0,0 +1,212 @@
-- =============================================================================
-- 闲管家 ERPerp_open_config / erp_goofish_order 一键升级脚本
-- =============================================================================
-- 用法:
-- 1. 先备份数据库;连接目标库后执行(或在文件开头加 USE your_database;
-- 2. 可重复执行:已存在的列/索引会自动跳过
-- 3. ADD COLUMN 一律不指定 AFTER避免旧表缺中间列时升级失败新列落在表尾不影响业务
-- 环境MySQL 5.7+ / 8.xMariaDB 未逐项验证)
-- =============================================================================
SET @schema := DATABASE();
-- -----------------------------------------------------------------------------
-- 1) 表不存在时创建完整结构(与 erp_goofish_init.sql 一致)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS erp_open_config (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
app_key varchar(64) NOT NULL COMMENT '开放平台 AppKey(appid)',
app_secret varchar(128) NOT NULL COMMENT '开放平台 AppSecret',
xy_user_name varchar(128) DEFAULT NULL COMMENT '默认闲鱼会员名(展示)',
express_code varchar(64) DEFAULT NULL COMMENT '发货用快递公司编码(日日顺物流在官方列表中为 rrs',
express_name varchar(64) DEFAULT NULL COMMENT '快递公司名称(展示)',
status char(1) NOT NULL DEFAULT '0' COMMENT '0正常 1停用',
order_num int(11) NOT NULL DEFAULT 0 COMMENT '排序(小优先)',
create_by varchar(64) DEFAULT '',
create_time datetime DEFAULT NULL,
update_by varchar(64) DEFAULT '',
update_time datetime DEFAULT NULL,
remark varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (id),
UNIQUE KEY uk_app_key (app_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闲管家开放平台应用配置';
CREATE TABLE IF NOT EXISTS erp_goofish_order (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
app_key varchar(64) NOT NULL COMMENT 'AppKey',
seller_id bigint(20) DEFAULT NULL COMMENT '商家ID',
user_name varchar(128) DEFAULT NULL COMMENT '闲鱼会员名',
order_no varchar(64) NOT NULL COMMENT '闲鱼订单号',
order_type int(11) DEFAULT NULL COMMENT '订单类型',
order_status int(11) DEFAULT NULL COMMENT '订单状态(推送/列表)',
refund_status int(11) DEFAULT NULL COMMENT '退款状态',
modify_time bigint(20) DEFAULT NULL COMMENT '订单更新时间(秒)',
product_id bigint(20) DEFAULT NULL COMMENT '管家商品ID',
item_id bigint(20) DEFAULT NULL COMMENT '闲鱼商品ID',
goods_title varchar(512) DEFAULT NULL COMMENT '商品标题(详情 goods.title)',
goods_image_url varchar(1024) DEFAULT NULL COMMENT '商品主图 URL(goods.images 首图)',
buyer_nick varchar(256) DEFAULT NULL COMMENT '买家昵称(详情 buyer_nick)',
pay_amount bigint(20) DEFAULT NULL COMMENT '实付金额(分) pay_amount',
detail_waybill_no varchar(128) DEFAULT NULL COMMENT '闲管家详情回传运单号 waybill_no',
detail_express_code varchar(64) DEFAULT NULL COMMENT '详情快递编码 express_code',
detail_express_name varchar(128) DEFAULT NULL COMMENT '详情快递名称 express_name',
receiver_name varchar(128) DEFAULT NULL COMMENT '收货人(详情有则落库)',
receiver_mobile varchar(64) DEFAULT NULL COMMENT '收货手机',
receiver_address varchar(1000) DEFAULT NULL COMMENT '收货详细地址(address)',
receiver_region varchar(256) DEFAULT NULL COMMENT '省市区街道等拼接展示',
recv_prov_name varchar(64) DEFAULT NULL COMMENT 'prov_name',
recv_city_name varchar(64) DEFAULT NULL COMMENT 'city_name',
recv_area_name varchar(64) DEFAULT NULL COMMENT 'area_name',
recv_town_name varchar(128) DEFAULT NULL COMMENT 'town_name',
detail_json longtext COMMENT '订单详情接口全量 JSON',
last_notify_json longtext COMMENT '最近一次推送原文 JSON',
jd_order_id bigint(20) DEFAULT NULL COMMENT '关联 jd_order.id第三方单号=闲鱼单号)',
local_waybill_no varchar(128) DEFAULT NULL COMMENT '本地物流扫描得到的运单号',
ship_status tinyint(4) NOT NULL DEFAULT '0' COMMENT '0未发货 1已调用发货成功 2发货失败',
ship_error varchar(500) DEFAULT NULL COMMENT '发货失败原因',
ship_time datetime DEFAULT NULL COMMENT '发货调用成功时间',
ship_express_code varchar(64) DEFAULT NULL COMMENT '实际发货使用的快递编码',
create_time datetime DEFAULT NULL,
update_time datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_app_order (app_key, order_no),
KEY idx_jd_order (jd_order_id),
KEY idx_order_status (order_status),
KEY idx_modify_time (modify_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闲管家 ERP 订单(全量跟踪)';
-- -----------------------------------------------------------------------------
-- 2) 存储过程:列不存在则 ADD
-- -----------------------------------------------------------------------------
DROP PROCEDURE IF EXISTS jarvis_erp_goofish_add_column;
DROP PROCEDURE IF EXISTS jarvis_erp_goofish_add_index;
DELIMITER $$
CREATE PROCEDURE jarvis_erp_goofish_add_column(
IN p_table VARCHAR(64),
IN p_column VARCHAR(64),
IN p_ddl TEXT
)
BEGIN
DECLARE col_exists INT DEFAULT 0;
SELECT COUNT(*) INTO col_exists
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @schema
AND TABLE_NAME = p_table
AND COLUMN_NAME = p_column;
IF col_exists = 0 THEN
SET @stmt := CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_ddl);
PREPARE ps FROM @stmt;
EXECUTE ps;
DEALLOCATE PREPARE ps;
END IF;
END$$
CREATE PROCEDURE jarvis_erp_goofish_add_index(
IN p_table VARCHAR(64),
IN p_index VARCHAR(64),
IN p_columns VARCHAR(200)
)
BEGIN
DECLARE idx_exists INT DEFAULT 0;
SELECT COUNT(*) INTO idx_exists
FROM information_schema.statistics
WHERE TABLE_SCHEMA = @schema
AND TABLE_NAME = p_table
AND INDEX_NAME = p_index;
IF idx_exists = 0 THEN
SET @stmt := CONCAT('ALTER TABLE `', p_table, '` ADD INDEX `', p_index, '` (', p_columns, ')');
PREPARE ps FROM @stmt;
EXECUTE ps;
DEALLOCATE PREPARE ps;
END IF;
END$$
DELIMITER ;
-- -----------------------------------------------------------------------------
-- 3) erp_goofish_order补齐缺列与当前 Java 实体 / erp_goofish_init.sql 一致)
-- -----------------------------------------------------------------------------
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'seller_id', 'bigint(20) NULL COMMENT ''商家ID''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'user_name', 'varchar(128) NULL COMMENT ''闲鱼会员名''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'order_type', 'int(11) NULL COMMENT ''订单类型''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'order_status', 'int(11) NULL COMMENT ''订单状态(推送/列表)''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'refund_status', 'int(11) NULL COMMENT ''退款状态''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'modify_time', 'bigint(20) NULL COMMENT ''订单更新时间(秒)''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'product_id', 'bigint(20) NULL COMMENT ''管家商品ID''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'item_id', 'bigint(20) NULL COMMENT ''闲鱼商品ID''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'goods_title', 'varchar(512) NULL COMMENT ''商品标题(详情 goods.title)''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'goods_image_url', 'varchar(1024) NULL COMMENT ''商品主图 URL(goods.images 首图)''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'buyer_nick', 'varchar(256) NULL COMMENT ''买家昵称(详情 buyer_nick)''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'pay_amount', 'bigint(20) NULL COMMENT ''实付金额(分) pay_amount''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'detail_waybill_no', 'varchar(128) NULL COMMENT ''闲管家详情回传运单号 waybill_no''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'detail_express_code', 'varchar(64) NULL COMMENT ''详情快递编码 express_code''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'detail_express_name', 'varchar(128) NULL COMMENT ''详情快递名称 express_name''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'receiver_name', 'varchar(128) NULL COMMENT ''收货人(详情有则落库)''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'receiver_mobile', 'varchar(64) NULL COMMENT ''收货手机''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'receiver_address', 'varchar(1000) NULL COMMENT ''收货详细地址(address)''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'receiver_region', 'varchar(256) NULL COMMENT ''省市区街道等拼接展示''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'recv_prov_name', 'varchar(64) NULL COMMENT ''prov_name''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'recv_city_name', 'varchar(64) NULL COMMENT ''city_name''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'recv_area_name', 'varchar(64) NULL COMMENT ''area_name''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'recv_town_name', 'varchar(128) NULL COMMENT ''town_name''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'detail_json', 'longtext NULL COMMENT ''订单详情接口全量 JSON''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'last_notify_json', 'longtext NULL COMMENT ''最近一次推送原文 JSON''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'jd_order_id', 'bigint(20) NULL COMMENT ''关联 jd_order.id第三方单号=闲鱼单号)''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'local_waybill_no', 'varchar(128) NULL COMMENT ''本地物流扫描得到的运单号''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'ship_status', 'tinyint(4) NOT NULL DEFAULT 0 COMMENT ''0未发货 1已调用发货成功 2发货失败''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'ship_error', 'varchar(500) NULL COMMENT ''发货失败原因''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'ship_time', 'datetime NULL COMMENT ''发货调用成功时间''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'ship_express_code', 'varchar(64) NULL COMMENT ''实际发货使用的快递编码''');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'create_time', 'datetime NULL');
CALL jarvis_erp_goofish_add_column('erp_goofish_order', 'update_time', 'datetime NULL');
-- -----------------------------------------------------------------------------
-- 4) 索引(手工建表可能缺)
-- -----------------------------------------------------------------------------
CALL jarvis_erp_goofish_add_index('erp_goofish_order', 'idx_jd_order', '`jd_order_id`');
CALL jarvis_erp_goofish_add_index('erp_goofish_order', 'idx_order_status', '`order_status`');
CALL jarvis_erp_goofish_add_index('erp_goofish_order', 'idx_modify_time', '`modify_time`');
-- -----------------------------------------------------------------------------
-- 5) 唯一键 uk_app_order
-- 若已存在同名约束则跳过;若 (app_key,order_no) 有重复行会报错,需先清洗数据。
-- 若已通过其它名称建了 (app_key,order_no) 唯一索引,请勿重复执行本节(可能报 Duplicate key
-- -----------------------------------------------------------------------------
SET @uk := (
SELECT COUNT(*) FROM information_schema.table_constraints
WHERE table_schema = @schema AND table_name = 'erp_goofish_order'
AND constraint_name = 'uk_app_order' AND constraint_type = 'UNIQUE'
);
SET @sql_uk := IF(@uk = 0,
'ALTER TABLE erp_goofish_order ADD UNIQUE KEY uk_app_order (app_key, order_no)',
'SELECT ''uk_app_order 已存在,跳过'' AS note');
PREPARE puk FROM @sql_uk;
EXECUTE puk;
DEALLOCATE PREPARE puk;
-- -----------------------------------------------------------------------------
-- 5.5) 订单变更事件日志表
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS erp_goofish_order_event_log (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
order_id bigint(20) NOT NULL COMMENT 'erp_goofish_order.id',
app_key varchar(64) DEFAULT NULL,
order_no varchar(64) NOT NULL,
event_type varchar(32) NOT NULL COMMENT 'ORDER_SYNC/LOGISTICS_SYNC/SHIP',
source varchar(64) NULL COMMENT 'NOTIFY/LIST/DETAIL_REFRESH 等',
message varchar(1024) NOT NULL,
create_time datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_goofish_evt_order (order_id),
KEY idx_goofish_evt_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闲管家订单状态/物流/发货变更日志';
-- -----------------------------------------------------------------------------
-- 6) 清理存储过程
-- -----------------------------------------------------------------------------
DROP PROCEDURE IF EXISTS jarvis_erp_goofish_add_column;
DROP PROCEDURE IF EXISTS jarvis_erp_goofish_add_index;
SELECT 'erp_goofish_upgrade.sql 执行结束' AS message;

View File

@@ -28,6 +28,11 @@
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -6,7 +6,7 @@ import com.alibaba.fastjson2.JSONObject;
* 授权列表查询请求(示例类) * 授权列表查询请求(示例类)
*/ */
public class AuthorizeListQueryRequest extends ERPRequestBase { public class AuthorizeListQueryRequest extends ERPRequestBase {
public AuthorizeListQueryRequest(ERPAccount erpAccount) { public AuthorizeListQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/user/authorize/list", erpAccount); super("https://open.goofish.pro/api/open/user/authorize/list", erpAccount);
} }

View File

@@ -9,7 +9,7 @@ import lombok.Getter;
* @descriptionERP账户枚举类 * @descriptionERP账户枚举类
*/ */
@Getter @Getter
public enum ERPAccount { public enum ERPAccount implements IERPAccount {
// 胡歌1016208368633221 // 胡歌1016208368633221
ACCOUNT_HUGE("1016208368633221", "waLiRMgFcixLbcLjUSSwo370Hp1nBcBu","余生请多关照66","海尔胡歌"), ACCOUNT_HUGE("1016208368633221", "waLiRMgFcixLbcLjUSSwo370Hp1nBcBu","余生请多关照66","海尔胡歌"),
// 刘强东anotherApiKey // 刘强东anotherApiKey

View File

@@ -18,12 +18,12 @@ public abstract class ERPRequestBase {
protected String url; protected String url;
protected String sign; protected String sign;
protected ERPAccount erpAccount; protected IERPAccount erpAccount;
@Setter @Setter
protected JSONObject requestBody; protected JSONObject requestBody;
protected long timestamp; // 统一时间戳字段 protected long timestamp; // 统一时间戳字段
public ERPRequestBase(String url, ERPAccount erpAccount) { public ERPRequestBase(String url, IERPAccount erpAccount) {
this.url = url; this.url = url;
this.erpAccount = erpAccount; this.erpAccount = erpAccount;
} }

View File

@@ -1,13 +1,18 @@
package com.ruoyi.erp.request; package com.ruoyi.erp.request;
import com.alibaba.fastjson2.JSONObject;
/** /**
* 查询快递公司请求 * 查询快递公司请求
* * <p>
* 对应接口POST /api/open/express/companies * 对应接口POST /api/open/express/companies<br>
* 闲管家规定:无业务参数时应对 <strong>空 JSON 对象</strong> 做 {@code md5("{}")},且 POST 原文必须与之一致;
* Apifox 调试时请勿留空 body须填 {@code {}},否则签名与平台不一致会拉不到数据。
*/ */
public class ExpressCompaniesQueryRequest extends ERPRequestBase { public class ExpressCompaniesQueryRequest extends ERPRequestBase {
public ExpressCompaniesQueryRequest(ERPAccount erpAccount) { public ExpressCompaniesQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/express/companies", erpAccount); super("https://open.goofish.pro/api/open/express/companies", erpAccount);
this.requestBody = new JSONObject();
} }
} }

View File

@@ -0,0 +1,14 @@
package com.ruoyi.erp.request;
/**
* 闲管家开放平台凭证({@link ERPAccount} 或库表配置行均可实现本接口)
*/
public interface IERPAccount {
String getApiKey();
String getApiKeySecret();
/** 闲鱼会员名(授权维度展示用,可为空) */
String getXyName();
}

View File

@@ -8,7 +8,7 @@ import com.alibaba.fastjson2.JSONObject;
* 对应接口POST /api/open/order/detail * 对应接口POST /api/open/order/detail
*/ */
public class OrderDetailQueryRequest extends ERPRequestBase { public class OrderDetailQueryRequest extends ERPRequestBase {
public OrderDetailQueryRequest(ERPAccount erpAccount) { public OrderDetailQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/order/detail", erpAccount); super("https://open.goofish.pro/api/open/order/detail", erpAccount);
} }

View File

@@ -8,7 +8,7 @@ import com.alibaba.fastjson2.JSONObject;
* 对应接口POST /api/open/order/kam/list * 对应接口POST /api/open/order/kam/list
*/ */
public class OrderKamListQueryRequest extends ERPRequestBase { public class OrderKamListQueryRequest extends ERPRequestBase {
public OrderKamListQueryRequest(ERPAccount erpAccount) { public OrderKamListQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/order/kam/list", erpAccount); super("https://open.goofish.pro/api/open/order/kam/list", erpAccount);
} }

View File

@@ -8,7 +8,7 @@ import com.alibaba.fastjson2.JSONObject;
* 对应接口POST /api/open/order/list * 对应接口POST /api/open/order/list
*/ */
public class OrderListQueryRequest extends ERPRequestBase { public class OrderListQueryRequest extends ERPRequestBase {
public OrderListQueryRequest(ERPAccount erpAccount) { public OrderListQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/order/list", erpAccount); super("https://open.goofish.pro/api/open/order/list", erpAccount);
} }

View File

@@ -8,7 +8,7 @@ import com.alibaba.fastjson2.JSONObject;
* 对应接口POST /api/open/order/modify/price * 对应接口POST /api/open/order/modify/price
*/ */
public class OrderModifyPriceRequest extends ERPRequestBase { public class OrderModifyPriceRequest extends ERPRequestBase {
public OrderModifyPriceRequest(ERPAccount erpAccount) { public OrderModifyPriceRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/order/modify/price", erpAccount); super("https://open.goofish.pro/api/open/order/modify/price", erpAccount);
} }

View File

@@ -8,7 +8,7 @@ import com.alibaba.fastjson2.JSONObject;
* 对应接口POST /api/open/order/ship * 对应接口POST /api/open/order/ship
*/ */
public class OrderShipRequest extends ERPRequestBase { public class OrderShipRequest extends ERPRequestBase {
public OrderShipRequest(ERPAccount erpAccount) { public OrderShipRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/order/ship", erpAccount); super("https://open.goofish.pro/api/open/order/ship", erpAccount);
} }

View File

@@ -13,7 +13,7 @@ import java.util.List;
* 限制每批次最多50个商品 * 限制每批次最多50个商品
*/ */
public class ProductBatchCreateRequest extends ERPRequestBase { public class ProductBatchCreateRequest extends ERPRequestBase {
public ProductBatchCreateRequest(ERPAccount erpAccount) { public ProductBatchCreateRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/batchCreate", erpAccount); super("https://open.goofish.pro/api/open/product/batchCreate", erpAccount);
} }

View File

@@ -12,7 +12,7 @@ import com.alibaba.fastjson2.JSONObject;
* - flash_sale_type选填闲鱼特卖类型 * - flash_sale_type选填闲鱼特卖类型
*/ */
public class ProductCategoryListQueryRequest extends ERPRequestBase { public class ProductCategoryListQueryRequest extends ERPRequestBase {
public ProductCategoryListQueryRequest(ERPAccount erpAccount) { public ProductCategoryListQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/category/list", erpAccount); super("https://open.goofish.pro/api/open/product/category/list", erpAccount);
} }

View File

@@ -15,7 +15,7 @@ public class ProductCreateRequest extends ERPRequestBase {
private final JSONArray publishShop = new JSONArray(); private final JSONArray publishShop = new JSONArray();
private final JSONArray skuItems = new JSONArray(); private final JSONArray skuItems = new JSONArray();
public ProductCreateRequest(ERPAccount erpAccount) { public ProductCreateRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/create", erpAccount); super("https://open.goofish.pro/api/open/product/create", erpAccount);
} }

View File

@@ -8,7 +8,7 @@ import com.alibaba.fastjson2.JSONObject;
* 对应接口POST /api/open/product/delete * 对应接口POST /api/open/product/delete
*/ */
public class ProductDeleteRequest extends ERPRequestBase { public class ProductDeleteRequest extends ERPRequestBase {
public ProductDeleteRequest(ERPAccount erpAccount) { public ProductDeleteRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/delete", erpAccount); super("https://open.goofish.pro/api/open/product/delete", erpAccount);
} }

View File

@@ -10,7 +10,7 @@ import com.alibaba.fastjson2.JSONObject;
* - product_id必填管家商品ID * - product_id必填管家商品ID
*/ */
public class ProductDetailQueryRequest extends ERPRequestBase { public class ProductDetailQueryRequest extends ERPRequestBase {
public ProductDetailQueryRequest(ERPAccount erpAccount) { public ProductDetailQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/detail", erpAccount); super("https://open.goofish.pro/api/open/product/detail", erpAccount);
} }

View File

@@ -8,7 +8,7 @@ import com.alibaba.fastjson2.JSONObject;
* 对应接口POST /api/open/product/downShelf * 对应接口POST /api/open/product/downShelf
*/ */
public class ProductDownShelfRequest extends ERPRequestBase { public class ProductDownShelfRequest extends ERPRequestBase {
public ProductDownShelfRequest(ERPAccount erpAccount) { public ProductDownShelfRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/downShelf", erpAccount); super("https://open.goofish.pro/api/open/product/downShelf", erpAccount);
} }

View File

@@ -15,7 +15,7 @@ public class ProductEditRequest extends ERPRequestBase {
private final JSONArray publishShop = new JSONArray(); private final JSONArray publishShop = new JSONArray();
private final JSONArray skuItems = new JSONArray(); private final JSONArray skuItems = new JSONArray();
public ProductEditRequest(ERPAccount erpAccount) { public ProductEditRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/edit", erpAccount); super("https://open.goofish.pro/api/open/product/edit", erpAccount);
} }

View File

@@ -9,7 +9,7 @@ import com.alibaba.fastjson2.JSONObject;
* 对应接口POST /api/open/product/edit/stock * 对应接口POST /api/open/product/edit/stock
*/ */
public class ProductEditStockRequest extends ERPRequestBase { public class ProductEditStockRequest extends ERPRequestBase {
public ProductEditStockRequest(ERPAccount erpAccount) { public ProductEditStockRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/edit/stock", erpAccount); super("https://open.goofish.pro/api/open/product/edit/stock", erpAccount);
} }

View File

@@ -3,7 +3,7 @@ package com.ruoyi.erp.request;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
public class ProductListQueryRequest extends ERPRequestBase { public class ProductListQueryRequest extends ERPRequestBase {
public ProductListQueryRequest(ERPAccount erpAccount) { public ProductListQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/list", erpAccount); super("https://open.goofish.pro/api/open/product/list", erpAccount);
} }

View File

@@ -13,7 +13,7 @@ import com.alibaba.fastjson2.JSONObject;
* - sub_property_id选填属性值ID用于二级属性查询 * - sub_property_id选填属性值ID用于二级属性查询
*/ */
public class ProductPropertyListQueryRequest extends ERPRequestBase { public class ProductPropertyListQueryRequest extends ERPRequestBase {
public ProductPropertyListQueryRequest(ERPAccount erpAccount) { public ProductPropertyListQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/pv/list", erpAccount); super("https://open.goofish.pro/api/open/product/pv/list", erpAccount);
} }

View File

@@ -9,7 +9,7 @@ import com.alibaba.fastjson2.JSONObject;
* 对应接口POST /api/open/product/publish * 对应接口POST /api/open/product/publish
*/ */
public class ProductPublishRequest extends ERPRequestBase { public class ProductPublishRequest extends ERPRequestBase {
public ProductPublishRequest(ERPAccount erpAccount) { public ProductPublishRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/publish", erpAccount); super("https://open.goofish.pro/api/open/product/publish", erpAccount);
} }

View File

@@ -12,7 +12,7 @@ import java.util.Collection;
* - product_id必填管家商品ID数组最多100个 * - product_id必填管家商品ID数组最多100个
*/ */
public class ProductSkuListQueryRequest extends ERPRequestBase { public class ProductSkuListQueryRequest extends ERPRequestBase {
public ProductSkuListQueryRequest(ERPAccount erpAccount) { public ProductSkuListQueryRequest(IERPAccount erpAccount) {
super("https://open.goofish.pro/api/open/product/sku/list", erpAccount); super("https://open.goofish.pro/api/open/product/sku/list", erpAccount);
} }

View File

@@ -0,0 +1,23 @@
package com.ruoyi.jarvis.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class GoofishAsyncConfig {
@Bean("goofishTaskExecutor")
public Executor goofishTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 仅用于未配置 RocketMQ 时 HTTP 回调路径的 @Async过小易在拉单/回调并发时排队拖慢企微通知
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("goofish-");
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,74 @@
package com.ruoyi.jarvis.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 闲管家订单MQ 主题、定时拉单与自动发货调度
*/
@Data
@Component
@ConfigurationProperties(prefix = "jarvis.goofish-order")
public class JarvisGoofishProperties {
/** RocketMQ Topic需配置 rocketmq.name-server 后生效) */
private String mqTopic = "jarvis-goofish-erp-order";
private String consumerGroup = "jarvis-goofish-order-consumer";
/** 回溯拉单小时数(定时/增量) */
private int pullLookbackHours = 72;
/** 拉单定时 cron */
private String pullCron = "0 * * * * ?";
/** 同步运单 + 自动发货 cron */
private String autoShipCron = "0 2/10 * * * ?";
/**
* 订单列表 page_size开放平台最大 100
*/
private int pullPageSize = 100;
/**
* 单次拉单每授权最大页数(开放平台 page_no 最大 100page_no×page_size 勿超过 10000
*/
private int pullMaxPagesPerShop = 100;
/**
* 全量/长历史拉单时,按 update_time 切片的窗口长度(秒),避免单窗内订单量过大触发平台限制
*/
private int pullTimeChunkSeconds = 604800;
/**
* 订单列表单次请求中 update_time 区间最大跨度。开放平台返回「只能查询时间范围6个月内的数据」时须≤此值默认约 180 天留余量
*/
private int pullMaxUpdateTimeRangeSeconds = 15552000;
/**
* 全量拉单从「当前时间」往前推多少天作为起点(仅 full 接口;可自行改大)
*/
private int pullFullHistoryDays = 1095;
private int autoShipBatchSize = 20;
/**
* 允许触发闲鱼开放平台「发货」的本地 order_status逗号分隔与推送/列表一致)。默认 12 通常表示待发货。
*/
private String autoShipOrderStatuses = "12";
/**
* 为 true 时定时/增量列表拉单仅按 {@link #autoShipOrderStatuses} 过滤(减少调用量,但会漏掉其它状态)。
* 默认 false按时间窗拉全状态与本地 upsert 对齐,推送仍可用于更低延迟。
*/
private boolean pullListOnlyAutoShipStatuses = false;
/**
* 未在 erp_open_config 配置 express_code 时,自动发货使用的默认快递公司编码(官方列表中日日顺多为 rrs
*/
private String defaultShipExpressCode = "rrs";
/** 与 defaultShipExpressCode 配套的展示名称 */
private String defaultShipExpressName = "日日顺";
}

View File

@@ -0,0 +1,77 @@
package com.ruoyi.jarvis.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
/**
* 闲管家 ERP 订单(推送 + 拉单 + 详情全量)
*/
@Data
public class ErpGoofishOrder {
private Long id;
private String appKey;
private Long sellerId;
private String userName;
private String orderNo;
private Integer orderType;
private Integer orderStatus;
private Integer refundStatus;
private Long modifyTime;
private Long productId;
private Long itemId;
/** 详情 goods.title */
private String goodsTitle;
/** goods.images 首张或其它单图字段 */
private String goodsImageUrl;
private String buyerNick;
/** 开放平台 pay_amount单位分 */
private Long payAmount;
/** 闲管家详情 waybill_no */
private String detailWaybillNo;
private String detailExpressCode;
private String detailExpressName;
private String receiverName;
private String receiverMobile;
private String receiverAddress;
/** 省市区拼接 */
private String receiverRegion;
/** 待发货等状态下开放平台返回的分级地址(与 prov_name/city_name/area_name/town_name 一致) */
private String recvProvName;
private String recvCityName;
private String recvAreaName;
private String recvTownName;
private String detailJson;
private String lastNotifyJson;
private Long jdOrderId;
private String localWaybillNo;
/** 0未发货 1成功 2失败 */
private Integer shipStatus;
private String shipError;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date shipTime;
private String shipExpressCode;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
/** 联查第三方单号jd_order */
private String jdThirdPartyOrderNo;
/** 联查:内部备注单号 */
private String jdRemark;
/** 联查:本地京东单收件地址 jd_order.address闲鱼详情常不返回明文地址 */
private String jdAddress;
// --------- 以下为列表查询扩展条件(不参与 insert/update ---------
/** 运单关键字:命中详情运单号或本地运单号(模糊) */
private String waybillKeyword;
/** 开放平台 modify_time 下限Unix 秒,含边界) */
private Long modifyTimeBegin;
/** 开放平台 modify_time 上限Unix 秒,含边界) */
private Long modifyTimeEnd;
/** 是否已关联京东单1 已关联 jd_order_id 非空0 未关联null 不限 */
private Integer jdLinkFilter;
}

View File

@@ -0,0 +1,26 @@
package com.ruoyi.jarvis.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
/**
* 闲管家订单变更日志:状态刷新、物流变动、发货结果等
*/
@Data
public class ErpGoofishOrderEventLog {
private Long id;
/** erp_goofish_order.id */
private Long orderId;
private String appKey;
private String orderNo;
/** ORDER_SYNC / LOGISTICS_SYNC / SHIP */
private String eventType;
/** NOTIFY、LIST、DETAIL_REFRESH、JD_LOGISTICS_PUSH、REDIS_WAYBILL、AUTO_SHIP 等 */
private String source;
private String message;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.jarvis.domain;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 闲管家订单变更日志(全表检索,用于排查)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ErpGoofishOrderEventLogQuery extends BaseEntity {
private Long orderId;
private String appKey;
private String orderNo;
private String eventType;
private String source;
/** 模糊匹配 message */
private String messageKeyword;
}

View File

@@ -0,0 +1,39 @@
package com.ruoyi.jarvis.domain;
import com.ruoyi.common.core.domain.BaseEntity;
import com.ruoyi.erp.request.IERPAccount;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 闲管家开放平台应用配置(配置中心)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ErpOpenConfig extends BaseEntity implements IERPAccount {
private Long id;
private String appKey;
private String appSecret;
private String xyUserName;
/** 发货:快递公司编码(如日日顺,需与开放平台一致) */
private String expressCode;
private String expressName;
private String status;
private Integer orderNum;
@Override
public String getApiKey() {
return appKey;
}
@Override
public String getApiKeySecret() {
return appSecret;
}
@Override
public String getXyName() {
return xyUserName;
}
}

View File

@@ -0,0 +1,478 @@
package com.ruoyi.jarvis.domain;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.utils.DateUtils;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 闲鱼商品导出专用行(字段偏多,便于给 AI / 离线分析使用)
*/
public class ErpProductExportRow implements Serializable
{
private static final long serialVersionUID = 1L;
@Excel(name = "导出批次时间", width = 22, sort = 1)
private String exportBatchAt;
@Excel(name = "本表主键ID", sort = 2)
private Long id;
@Excel(name = "管家商品ID", sort = 3)
private Long productId;
@Excel(name = "管家商品ID文本", width = 22, sort = 4)
private String productIdText;
@Excel(name = "商品标题", width = 45, sort = 5)
private String title;
@Excel(name = "主图URL", width = 55, sort = 6)
private String mainImage;
@Excel(name = "价格_分_原始整数", sort = 7)
private Long priceFen;
@Excel(name = "价格_元_可读", sort = 8)
private String priceYuan;
@Excel(name = "库存", sort = 9)
private Integer stock;
@Excel(name = "商品状态_码", sort = 10)
private Integer productStatusCode;
@Excel(name = "商品状态_说明", width = 14, sort = 11)
private String productStatusLabel;
@Excel(name = "销售状态_码", sort = 12)
private Integer saleStatusCode;
@Excel(name = "闲鱼会员名", width = 18, sort = 13)
private String userName;
@Excel(name = "ERP应用appid", width = 22, sort = 14)
private String appid;
@Excel(name = "商品链接", width = 55, sort = 15)
private String productUrl;
@Excel(name = "上架时间_unix秒", width = 18, sort = 16)
private Long onlineTimeUnix;
@Excel(name = "上架时间_可读", width = 22, sort = 17)
private String onlineTimeReadable;
@Excel(name = "下架时间_unix秒", width = 18, sort = 18)
private Long offlineTimeUnix;
@Excel(name = "下架时间_可读", width = 22, sort = 19)
private String offlineTimeReadable;
@Excel(name = "售出时间_unix秒", width = 18, sort = 20)
private Long soldTimeUnix;
@Excel(name = "售出时间_可读", width = 22, sort = 21)
private String soldTimeReadable;
@Excel(name = "闲鱼创建_unix秒", width = 18, sort = 22)
private Long createTimeXyUnix;
@Excel(name = "闲鱼创建_可读", width = 22, sort = 23)
private String createTimeXyReadable;
@Excel(name = "闲鱼更新_unix秒", width = 18, sort = 24)
private Long updateTimeXyUnix;
@Excel(name = "闲鱼更新_可读", width = 22, sort = 25)
private String updateTimeXyReadable;
@Excel(name = "备注_本表", width = 30, sort = 26)
private String remark;
@Excel(name = "库创建时间", width = 22, dateFormat = "yyyy-MM-dd HH:mm:ss", sort = 27)
private Date dbCreateTime;
@Excel(name = "库更新时间", width = 22, dateFormat = "yyyy-MM-dd HH:mm:ss", sort = 28)
private Date dbUpdateTime;
public static List<ErpProductExportRow> fromList(List<ErpProduct> list, String exportBatchAt)
{
List<ErpProductExportRow> rows = new ArrayList<>(list.size());
for (ErpProduct p : list)
{
rows.add(from(p, exportBatchAt));
}
return rows;
}
public static ErpProductExportRow from(ErpProduct p, String exportBatchAt)
{
ErpProductExportRow r = new ErpProductExportRow();
r.setExportBatchAt(exportBatchAt != null ? exportBatchAt : "");
if (p == null)
{
return r;
}
r.setId(p.getId());
r.setProductId(p.getProductId());
r.setProductIdText(p.getProductId() != null ? String.valueOf(p.getProductId()) : "");
r.setTitle(p.getTitle());
r.setMainImage(p.getMainImage());
r.setPriceFen(p.getPrice());
if (p.getPrice() != null)
{
r.setPriceYuan(BigDecimal.valueOf(p.getPrice()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP).toPlainString());
}
else
{
r.setPriceYuan("");
}
r.setStock(p.getStock());
r.setProductStatusCode(p.getProductStatus());
r.setProductStatusLabel(productStatusLabel(p.getProductStatus()));
r.setSaleStatusCode(p.getSaleStatus());
r.setUserName(p.getUserName());
r.setAppid(p.getAppid());
r.setProductUrl(p.getProductUrl());
r.setOnlineTimeUnix(p.getOnlineTime());
r.setOnlineTimeReadable(formatUnixSeconds(p.getOnlineTime()));
r.setOfflineTimeUnix(p.getOfflineTime());
r.setOfflineTimeReadable(formatUnixSeconds(p.getOfflineTime()));
r.setSoldTimeUnix(p.getSoldTime());
r.setSoldTimeReadable(formatUnixSeconds(p.getSoldTime()));
r.setCreateTimeXyUnix(p.getCreateTimeXy());
r.setCreateTimeXyReadable(formatUnixSeconds(p.getCreateTimeXy()));
r.setUpdateTimeXyUnix(p.getUpdateTimeXy());
r.setUpdateTimeXyReadable(formatUnixSeconds(p.getUpdateTimeXy()));
r.setRemark(p.getRemark());
r.setDbCreateTime(p.getCreateTime());
r.setDbUpdateTime(p.getUpdateTime());
return r;
}
private static String formatUnixSeconds(Long unixSeconds)
{
if (unixSeconds == null || unixSeconds <= 0)
{
return "";
}
return DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, new Date(unixSeconds * 1000L));
}
private static String productStatusLabel(Integer status)
{
if (status == null)
{
return "";
}
switch (status)
{
case -1:
return "删除";
case 10:
return "其它(10)";
case 21:
return "待发布";
case 22:
return "销售中";
case 23:
return "已售罄";
case 31:
return "手动下架";
case 33:
return "售出下架";
case 36:
return "自动下架";
default:
return "未知(" + status + ")";
}
}
public String getExportBatchAt()
{
return exportBatchAt;
}
public void setExportBatchAt(String exportBatchAt)
{
this.exportBatchAt = exportBatchAt;
}
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public Long getProductId()
{
return productId;
}
public void setProductId(Long productId)
{
this.productId = productId;
}
public String getProductIdText()
{
return productIdText;
}
public void setProductIdText(String productIdText)
{
this.productIdText = productIdText;
}
public String getTitle()
{
return title;
}
public void setTitle(String title)
{
this.title = title;
}
public String getMainImage()
{
return mainImage;
}
public void setMainImage(String mainImage)
{
this.mainImage = mainImage;
}
public Long getPriceFen()
{
return priceFen;
}
public void setPriceFen(Long priceFen)
{
this.priceFen = priceFen;
}
public String getPriceYuan()
{
return priceYuan;
}
public void setPriceYuan(String priceYuan)
{
this.priceYuan = priceYuan;
}
public Integer getStock()
{
return stock;
}
public void setStock(Integer stock)
{
this.stock = stock;
}
public Integer getProductStatusCode()
{
return productStatusCode;
}
public void setProductStatusCode(Integer productStatusCode)
{
this.productStatusCode = productStatusCode;
}
public String getProductStatusLabel()
{
return productStatusLabel;
}
public void setProductStatusLabel(String productStatusLabel)
{
this.productStatusLabel = productStatusLabel;
}
public Integer getSaleStatusCode()
{
return saleStatusCode;
}
public void setSaleStatusCode(Integer saleStatusCode)
{
this.saleStatusCode = saleStatusCode;
}
public String getUserName()
{
return userName;
}
public void setUserName(String userName)
{
this.userName = userName;
}
public String getAppid()
{
return appid;
}
public void setAppid(String appid)
{
this.appid = appid;
}
public String getProductUrl()
{
return productUrl;
}
public void setProductUrl(String productUrl)
{
this.productUrl = productUrl;
}
public Long getOnlineTimeUnix()
{
return onlineTimeUnix;
}
public void setOnlineTimeUnix(Long onlineTimeUnix)
{
this.onlineTimeUnix = onlineTimeUnix;
}
public String getOnlineTimeReadable()
{
return onlineTimeReadable;
}
public void setOnlineTimeReadable(String onlineTimeReadable)
{
this.onlineTimeReadable = onlineTimeReadable;
}
public Long getOfflineTimeUnix()
{
return offlineTimeUnix;
}
public void setOfflineTimeUnix(Long offlineTimeUnix)
{
this.offlineTimeUnix = offlineTimeUnix;
}
public String getOfflineTimeReadable()
{
return offlineTimeReadable;
}
public void setOfflineTimeReadable(String offlineTimeReadable)
{
this.offlineTimeReadable = offlineTimeReadable;
}
public Long getSoldTimeUnix()
{
return soldTimeUnix;
}
public void setSoldTimeUnix(Long soldTimeUnix)
{
this.soldTimeUnix = soldTimeUnix;
}
public String getSoldTimeReadable()
{
return soldTimeReadable;
}
public void setSoldTimeReadable(String soldTimeReadable)
{
this.soldTimeReadable = soldTimeReadable;
}
public Long getCreateTimeXyUnix()
{
return createTimeXyUnix;
}
public void setCreateTimeXyUnix(Long createTimeXyUnix)
{
this.createTimeXyUnix = createTimeXyUnix;
}
public String getCreateTimeXyReadable()
{
return createTimeXyReadable;
}
public void setCreateTimeXyReadable(String createTimeXyReadable)
{
this.createTimeXyReadable = createTimeXyReadable;
}
public Long getUpdateTimeXyUnix()
{
return updateTimeXyUnix;
}
public void setUpdateTimeXyUnix(Long updateTimeXyUnix)
{
this.updateTimeXyUnix = updateTimeXyUnix;
}
public String getUpdateTimeXyReadable()
{
return updateTimeXyReadable;
}
public void setUpdateTimeXyReadable(String updateTimeXyReadable)
{
this.updateTimeXyReadable = updateTimeXyReadable;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
public Date getDbCreateTime()
{
return dbCreateTime;
}
public void setDbCreateTime(Date dbCreateTime)
{
this.dbCreateTime = dbCreateTime;
}
public Date getDbUpdateTime()
{
return dbUpdateTime;
}
public void setDbUpdateTime(Date dbUpdateTime)
{
this.dbUpdateTime = dbUpdateTime;
}
}

View File

@@ -30,6 +30,10 @@ public class JDOrder extends BaseEntity {
@Excel(name = "型号") @Excel(name = "型号")
private String modelNumber; private String modelNumber;
/** 列表筛选:型号不含此子串(对应 SQL NOT LIKE %值%),不入库 */
@Transient
private String modelNumberExclude;
/** 链接 */ /** 链接 */
@Excel(name = "链接") @Excel(name = "链接")
private String link; private String link;

View File

@@ -59,7 +59,7 @@ public class TencentDocBatchPushRecord extends BaseEntity {
/** 错误数量 */ /** 错误数量 */
private Integer errorCount; private Integer errorCount;
/** 状态RUNNING-执行中SUCCESS-成功PARTIAL-部分成功FAILED-失败 */ /** 状态RUNNING-执行中SUCCESS-成功PARTIAL-部分成功FAILED-失败INTERRUPTED-已中断(超时/未正常结束) */
private String status; private String status;
/** 结果消息 */ /** 结果消息 */

View File

@@ -4,7 +4,8 @@ import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity; import com.ruoyi.common.core.domain.BaseEntity;
/** /**
* 企微分享链物流任务 wecom_share_link_logistics_job * 企微分享链物流任务 wecom_share_link_logistics_job
* 状态含 CANCELLED不再参与对账入队与队列扫描订单取消等场景
*/ */
public class WeComShareLinkLogisticsJob extends BaseEntity { public class WeComShareLinkLogisticsJob extends BaseEntity {

View File

@@ -0,0 +1,18 @@
package com.ruoyi.jarvis.domain.dto;
import lombok.Data;
/**
* 快捷录单页:型号下拉项及该型号最近一次落库单的付款 / 后返
*/
@Data
public class QuickRecordModelOption {
private String modelNumber;
/** 最近一次订单的下单付款金额 */
private Double lastPaymentAmount;
/** 最近一次订单的后返金额 */
private Double lastRebateAmount;
}

View File

@@ -0,0 +1,10 @@
package com.ruoyi.jarvis.dto;
import lombok.Data;
@Data
public class GoofishNotifyMessage {
private String appid;
private Long timestamp;
private String body;
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.ErpGoofishOrderEventLog;
import com.ruoyi.jarvis.domain.ErpGoofishOrderEventLogQuery;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface ErpGoofishOrderEventLogMapper {
int insert(ErpGoofishOrderEventLog row);
List<ErpGoofishOrderEventLog> selectByOrderId(@Param("orderId") Long orderId);
List<ErpGoofishOrderEventLog> selectLogList(ErpGoofishOrderEventLogQuery query);
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.ErpGoofishOrder;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface ErpGoofishOrderMapper {
ErpGoofishOrder selectById(Long id);
ErpGoofishOrder selectByAppKeyAndOrderNo(@Param("appKey") String appKey, @Param("orderNo") String orderNo);
List<ErpGoofishOrder> selectList(ErpGoofishOrder query);
int insert(ErpGoofishOrder row);
int update(ErpGoofishOrder row);
List<ErpGoofishOrder> selectPendingShip(@Param("statuses") List<Integer> statuses, @Param("limit") int limit);
/**
* 按闲鱼买家订单号(与 jd_order.third_party_order_no 对齐时)检索,用于京东运单就绪后补绑发货。
*/
List<ErpGoofishOrder> selectByGoofishOrderNo(@Param("orderNo") String orderNo);
int resetShipForRetry(@Param("id") Long id);
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.ErpOpenConfig;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface ErpOpenConfigMapper {
ErpOpenConfig selectById(Long id);
ErpOpenConfig selectByAppKey(@Param("appKey") String appKey);
List<ErpOpenConfig> selectList(ErpOpenConfig query);
List<ErpOpenConfig> selectEnabledOrderBySort();
int insert(ErpOpenConfig row);
int update(ErpOpenConfig row);
int deleteById(Long id);
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.jarvis.mapper; package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.dto.QuickRecordModelOption;
import java.util.List; import java.util.List;
/** /**
@@ -61,6 +62,11 @@ public interface JDOrderMapper {
* @return 订单列表 * @return 订单列表
*/ */
List<JDOrder> selectJDOrderListByDistributionMarkFOrPDD(); List<JDOrder> selectJDOrderListByDistributionMarkFOrPDD();
/**
* 每个型号取其主键最大的一条订单的付款 / 后返(用于快捷录单下拉回填)
*/
List<QuickRecordModelOption> selectQuickRecordModelOptions();
} }

View File

@@ -3,6 +3,7 @@ package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.TencentDocBatchPushRecord; import com.ruoyi.jarvis.domain.TencentDocBatchPushRecord;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List; import java.util.List;
/** /**
@@ -41,5 +42,11 @@ public interface TencentDocBatchPushRecordMapper {
*/ */
TencentDocBatchPushRecord selectLastSuccessRecord(@Param("fileId") String fileId, TencentDocBatchPushRecord selectLastSuccessRecord(@Param("fileId") String fileId,
@Param("sheetId") String sheetId); @Param("sheetId") String sheetId);
/**
* 仍为 RUNNING 且开始时间早于指定时间的批次(用于超时归档)
*/
List<TencentDocBatchPushRecord> selectRunningRecordsBefore(@Param("fileId") String fileId,
@Param("beforeTime") Date beforeTime);
} }

View File

@@ -25,4 +25,17 @@ public interface WeComShareLinkLogisticsJobMapper {
* 仅 {@code create_time} 在最近一个月内的记录,避免扫到过旧历史。 * 仅 {@code create_time} 在最近一个月内的记录,避免扫到过旧历史。
*/ */
List<WeComShareLinkLogisticsJob> selectJobsNeedingQueueReconcile(@Param("limit") int limit); List<WeComShareLinkLogisticsJob> selectJobsNeedingQueueReconcile(@Param("limit") int limit);
int deleteByJobKey(@Param("jobKey") String jobKey);
/**
* 机器人「京外物列表」:最近若干天内的任务,按 id 倒序,可选备注子串筛选。
*/
List<WeComShareLinkLogisticsJob> selectRecentForInstruction(@Param("remarkKeyword") String remarkKeyword,
@Param("days") int days, @Param("limit") int limit);
/**
* 机器人「京外物删」按备注与短链精确匹配trim物理删除返回删除行数。
*/
int deleteByRemarkAndTrackingUrl(@Param("remark") String remark, @Param("trackingUrl") String trackingUrl);
} }

View File

@@ -0,0 +1,44 @@
package com.ruoyi.jarvis.mq;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.jarvis.dto.GoofishNotifyMessage;
import com.ruoyi.jarvis.service.goofish.GoofishOrderPipeline;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 闲管家订单推送消费(需配置 rocketmq.name-server
* <p>
* 在此线程内同步执行 {@link GoofishOrderPipeline#runFullPipeline},避免再投递 {@code @Async} 线程池造成
* 「MQ 堆积 + goofishTaskExecutor 排队」的双重延迟;回调 HTTP 已在 {@link com.ruoyi.jarvis.service.impl.ErpGoofishOrderServiceImpl#publishOrProcessNotify} 中快速返回。
*/
@Component
@ConditionalOnProperty(name = "rocketmq.name-server")
@RocketMQMessageListener(
nameServer = "${rocketmq.name-server}",
topic = "${jarvis.goofish-order.mq-topic:jarvis-goofish-erp-order}",
consumerGroup = "${jarvis.goofish-order.consumer-group:jarvis-goofish-order-consumer}"
)
public class GoofishOrderNotifyConsumer implements RocketMQListener<String> {
@Resource
private GoofishOrderPipeline goofishOrderPipeline;
@Override
public void onMessage(String message) {
GoofishNotifyMessage m = JSON.parseObject(message, GoofishNotifyMessage.class);
if (m == null || m.getAppid() == null) {
return;
}
JSONObject body = m.getBody() == null ? new JSONObject() : JSON.parseObject(m.getBody());
if (body == null) {
body = new JSONObject();
}
goofishOrderPipeline.runFullPipeline(m.getAppid(), body);
}
}

View File

@@ -0,0 +1,56 @@
package com.ruoyi.jarvis.service;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.jarvis.domain.ErpGoofishOrder;
import com.ruoyi.jarvis.domain.ErpGoofishOrderEventLog;
import com.ruoyi.jarvis.domain.ErpGoofishOrderEventLogQuery;
import java.util.List;
public interface IErpGoofishOrderService {
void publishOrProcessNotify(String appid, Long timestamp, JSONObject body);
/**
* MQ 消费者或线程池:写库、拉详情、关联京东单、同步运单、尝试发货
*/
void asyncPipelineAfterNotify(String appid, JSONObject notifyBody);
List<ErpGoofishOrder> selectList(ErpGoofishOrder query);
ErpGoofishOrder selectById(Long id);
int pullOrdersForAppKey(String appKey, int lookbackHours);
int pullAllEnabled(int lookbackHours);
/** 按配置的起始天数 + 时间分段尽量拉全历史订单update_time */
int pullOrdersForAppKeyFullHistory(String appKey);
int pullAllEnabledFullHistory();
void refreshDetail(Long id);
void retryShip(Long id);
int syncWaybillAndTryShipBatch(int limit);
void applyListOrNotifyItem(String appKey, JSONObject item, String lastNotifyJson);
/**
* 京东单物流扫描已得到运单号并写入 Redis 后调用:同步到闲鱼单并尝试开放平台发货。
*/
void notifyJdWaybillReady(Long jdOrderId);
/** 京东物流服务在写 Redis / 企微货主推送后、触发闲鱼同步前记一笔source=JD_LOGISTICS_PUSH。 */
void traceJdLogisticsPushForGoofish(Long jdOrderId, String waybillNo, String summary);
/** 是否存在关联本京东单的闲管家订单(用于物流企微走闲鱼自建应用)。 */
boolean hasLinkedGoofishOrder(Long jdOrderId);
/** 订单状态 / 物流 / 发货 变更日志(新→旧) */
List<ErpGoofishOrderEventLog> listEventLogsByOrderId(Long orderId);
/** 全表分页检索(排查用,配合 PageHelper */
List<ErpGoofishOrderEventLog> selectEventLogList(ErpGoofishOrderEventLogQuery query);
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.jarvis.service;
import com.ruoyi.jarvis.domain.ErpOpenConfig;
import java.util.List;
public interface IErpOpenConfigService {
ErpOpenConfig selectById(Long id);
ErpOpenConfig selectByAppKey(String appKey);
ErpOpenConfig selectFirstEnabled();
List<ErpOpenConfig> selectList(ErpOpenConfig query);
List<ErpOpenConfig> selectEnabledOrderBySort();
int insert(ErpOpenConfig row);
int update(ErpOpenConfig row);
int deleteById(Long id);
}

View File

@@ -28,6 +28,12 @@ public interface IInstructionService {
*/ */
java.util.List<String> execute(String command, boolean forceGenerate, boolean isFromConsole); java.util.List<String> execute(String command, boolean forceGenerate, boolean isFromConsole);
/**
* 执行文本指令(支持传入企微成员 UserID用于「京」统计按绑定联盟过滤
* @param wecomUserId 企业微信成员 UserID控制台等非企微入口传 null按全局规则统计
*/
java.util.List<String> execute(String command, boolean forceGenerate, boolean isFromConsole, String wecomUserId);
/** /**
* 获取历史消息记录 * 获取历史消息记录
* @param type 消息类型request(请求) 或 response(响应) * @param type 消息类型request(请求) 或 response(响应)

View File

@@ -2,6 +2,8 @@ package com.ruoyi.jarvis.service;
import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.JDOrder;
import java.util.List;
/** /**
* 订单利润/售价:按分销标识规则计算并写回订单对象(由列表保存前调用)。 * 订单利润/售价:按分销标识规则计算并写回订单对象(由列表保存前调用)。
*/ */
@@ -9,7 +11,16 @@ public interface IJDOrderProfitService {
/** /**
* 根据分销标识、型号配置、手动标记等,填充售价(自动时)并计算利润。 * 根据分销标识、型号配置、手动标记等,填充售价(自动时)并计算利润。
* F / H-TF利润 = 对客实收(直款=售价,闲鱼=扣点后的到账)-(下单付款 - 后返金额);
* H-TF 未配置型号直款价时回退为固定 15 / 凡- 开头 65。
* 会修改传入的 {@code order}。 * 会修改传入的 {@code order}。
*/ */
void recalculate(JDOrder order); void recalculate(JDOrder order);
/**
* 对「利润未手动锁定」的订单按当前库内数据重算售价/利润字段;仅当计算结果与库中不一致时才 UPDATE。
*
* @return 实际执行 UPDATE 的条数
*/
int syncAutoProfitIfChanged(List<Long> ids);
} }

View File

@@ -1,6 +1,7 @@
package com.ruoyi.jarvis.service; package com.ruoyi.jarvis.service;
import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.dto.QuickRecordModelOption;
import java.util.List; import java.util.List;
/** /**
@@ -48,6 +49,9 @@ public interface IJDOrderService {
/** 查询分销标记为F或PDD且有物流链接的订单列表 */ /** 查询分销标记为F或PDD且有物流链接的订单列表 */
java.util.List<JDOrder> selectJDOrderListByDistributionMarkFOrPDD(); java.util.List<JDOrder> selectJDOrderListByDistributionMarkFOrPDD();
/** 快捷录单:型号及最近一次单的付款 / 后返 */
List<QuickRecordModelOption> selectQuickRecordModelOptions();
} }

View File

@@ -69,6 +69,14 @@ public interface ILogisticsService {
*/ */
HealthCheckResult checkHealth(); HealthCheckResult checkHealth();
/**
* 构造调用物流解析服务的完整 GET URL路径与编码与 {@link #fetchLogisticsAndPush} 一致)。
* 配置多个 {@code jarvis.server.logistics.base-urls} 时按轮询选取 base便于内网多实例并行。
*
* @param logisticsLink 原始物流追踪链接(未编码)
*/
String buildFetchLogisticsRequestUrl(String logisticsLink);
/** /**
* 健康检测结果 * 健康检测结果
*/ */

View File

@@ -65,13 +65,17 @@ public interface ISocialMediaService
com.ruoyi.common.core.domain.AjaxResult deletePromptTemplate(String key); com.ruoyi.common.core.domain.AjaxResult deletePromptTemplate(String key);
/** /**
* 根据标题(+可选型号备注)生成闲鱼文案(代下单、教你下单),不依赖JD接口 * 生成闲鱼文案(代下单、教你下单),并可选生成种草文案。
* *
* @param title 商品标题(必填) * request 常用字段:
* @param remark 型号/备注(可选 * - title/remark兼容旧版手动入口
* @return 包含代下单、教你下单两种文案的 Map * - generateSeedNote(boolean) 是否额外生成种草文案
* - goods_title/goods_model/goods_type种草文案必传
* - goods_brand/channel_source/official_price/sell_price/warranty/delivery_install可选
*
* @return 包含代下单、教你下单,以及可选 seedNote 的 Map
*/ */
Map<String, Object> generateXianyuWenan(String title, String remark); Map<String, Object> generateXianyuWenan(Map<String, Object> request);
/** 列出多套大模型接入配置及当前激活的 id与 Jarvis 共用 Redis */ /** 列出多套大模型接入配置及当前激活的 id与 Jarvis 共用 Redis */
com.ruoyi.common.core.domain.AjaxResult listLlmProfiles(); com.ruoyi.common.core.domain.AjaxResult listLlmProfiles();

View File

@@ -41,5 +41,10 @@ public interface ITencentDocBatchPushService {
* 获取推送状态和倒计时信息 * 获取推送状态和倒计时信息
*/ */
Map<String, Object> getPushStatusAndCountdown(); Map<String, Object> getPushStatusAndCountdown();
/**
* 将长时间仍处于 RUNNING 的批次归档为 INTERRUPTED并可选发企微告警见实现类配置
*/
void reconcileStaleRunningRecords(String fileId);
} }

View File

@@ -9,7 +9,7 @@ import com.ruoyi.jarvis.domain.dto.WeComInboundResult;
public interface IWeComInboundService { public interface IWeComInboundService {
/** /**
* 首条进入被动回复;其余由控制器异步调 wxSend /wecom/active-push。 * 长文本按企微上限拆成多段(每段 ≤2048 UTF-8 字节):首段被动回复,后续段由控制器异步调 wxSend /wecom/active-push。
*/ */
WeComInboundResult handleInbound(WeComInboundRequest request); WeComInboundResult handleInbound(WeComInboundRequest request);
} }

View File

@@ -16,4 +16,10 @@ public interface IWeComShareLinkLogisticsJobService {
* jobKey 固定为 tracebf{traceId},可重复执行跳过已存在项。 * jobKey 固定为 tracebf{traceId},可重复执行跳过已存在项。
*/ */
Map<String, Object> backfillImportedFromInboundTrace(); Map<String, Object> backfillImportedFromInboundTrace();
List<WeComShareLinkLogisticsJob> selectRecentForInstruction(String remarkKeyword, int days, int limit);
int deleteByJobKey(String jobKey);
int deleteByRemarkAndTrackingUrl(String remark, String trackingUrl);
} }

View File

@@ -5,11 +5,16 @@ package com.ruoyi.jarvis.service;
*/ */
public interface IWxSendService { public interface IWxSendService {
/** /**
* 检查微信推送服务健康状态 * 检查微信推送服务健康状态(会真实下发一条测试消息,仅用于服务监控页「手动测试」)
* @return 健康状态信息,包含是否健康、状态描述等 * @return 健康状态信息,包含是否健康、状态描述等
*/ */
HealthCheckResult checkHealth(); HealthCheckResult checkHealth();
/**
* 已配置的微信推送健康检查 URL展示用不发起请求
*/
String getHealthCheckServiceUrl();
/** /**
* 健康检测结果 * 健康检测结果
*/ */

View File

@@ -0,0 +1,56 @@
package com.ruoyi.jarvis.service.erp;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.erp.request.ERPAccount;
import com.ruoyi.erp.request.IERPAccount;
import com.ruoyi.jarvis.domain.ErpOpenConfig;
import com.ruoyi.jarvis.service.IErpOpenConfigService;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
public class ErpAccountResolver {
@Resource
private IErpOpenConfigService erpOpenConfigService;
/**
* 优先库表启用配置,其次兼容历史枚举,最后默认胡歌
*/
public IERPAccount resolve(String appid) {
if (StringUtils.isNotEmpty(appid)) {
ErpOpenConfig cfg = erpOpenConfigService.selectByAppKey(appid.trim());
if (cfg != null && "0".equals(cfg.getStatus())) {
return cfg;
}
for (ERPAccount a : ERPAccount.values()) {
if (a.getApiKey().equals(appid.trim())) {
return a;
}
}
}
ErpOpenConfig first = erpOpenConfigService.selectFirstEnabled();
if (first != null) {
return first;
}
return ERPAccount.ACCOUNT_HUGE;
}
/** 校验回调签名时须严格对应 appid不允许回落到默认账号 */
public IERPAccount resolveStrict(String appid) {
if (StringUtils.isEmpty(appid)) {
return null;
}
ErpOpenConfig cfg = erpOpenConfigService.selectByAppKey(appid.trim());
if (cfg != null && "0".equals(cfg.getStatus())) {
return cfg;
}
for (ERPAccount a : ERPAccount.values()) {
if (a.getApiKey().equals(appid.trim())) {
return a;
}
}
return null;
}
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.jarvis.service.goofish;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
public class GoofishNotifyAsyncFacade {
@Resource
private GoofishOrderPipeline goofishOrderPipeline;
@Async("goofishTaskExecutor")
public void afterNotify(String appid, JSONObject body) {
goofishOrderPipeline.runFullPipeline(appid, body);
}
}

View File

@@ -0,0 +1,169 @@
package com.ruoyi.jarvis.service.goofish;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.jarvis.domain.ErpGoofishOrder;
import com.ruoyi.jarvis.domain.ErpGoofishOrderEventLog;
import com.ruoyi.jarvis.mapper.ErpGoofishOrderEventLogMapper;
import com.ruoyi.jarvis.wecom.WxSendGoofishNotifyClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* 闲管家订单:状态 / 物流 / 发货 变更落库 + SLF4J
*/
@Component
public class GoofishOrderChangeLogger {
private static final Logger log = LoggerFactory.getLogger(GoofishOrderChangeLogger.class);
private static final int MESSAGE_MAX = 1000;
public static final String TYPE_ORDER = "ORDER_SYNC";
public static final String TYPE_LOGISTICS = "LOGISTICS_SYNC";
public static final String TYPE_SHIP = "SHIP";
/**
* 京东物流扫描:货主通知已由 LogisticsServiceImpl 走闲鱼自建应用或 PDD此处仅落库避免再调 wxSend 重复推送。
*/
public static final String SOURCE_JD_LOGISTICS_PUSH = "JD_LOGISTICS_PUSH";
/**
* Redis 写入本地运单:与后续详情运单合并、发货成功通知重复度高,仅落库便于对账,不推企微。
*/
public static final String SOURCE_REDIS_WAYBILL = "REDIS_WAYBILL";
@Resource
private ErpGoofishOrderEventLogMapper erpGoofishOrderEventLogMapper;
@Resource
private WxSendGoofishNotifyClient wxSendGoofishNotifyClient;
public void append(Long orderId, String appKey, String orderNo, String eventType, String source, String message) {
append(orderId, appKey, orderNo, eventType, source, message, true);
}
/**
* @param notifyWecom 为 false 时仅写事件日志,不推企微(用于过滤纯对齐类状态)。
*/
public void append(Long orderId, String appKey, String orderNo, String eventType, String source, String message,
boolean notifyWecom) {
if (orderId == null || StringUtils.isEmpty(message)) {
return;
}
String msg = message.length() > MESSAGE_MAX ? message.substring(0, MESSAGE_MAX - 1) + "" : message;
ErpGoofishOrderEventLog row = new ErpGoofishOrderEventLog();
row.setOrderId(orderId);
row.setAppKey(appKey);
row.setOrderNo(orderNo != null ? orderNo : "");
row.setEventType(eventType != null ? eventType : "UNKNOWN");
row.setSource(source != null ? source : "");
row.setMessage(msg);
row.setCreateTime(DateUtils.getNowDate());
try {
erpGoofishOrderEventLogMapper.insert(row);
} catch (Exception e) {
log.warn("闲管家订单事件日志写入失败 orderId={} {}", orderId, e.toString());
return;
}
log.info("[goofish-order-event] orderId={} orderNo={} type={} source={} {}", orderId, orderNo, eventType, source, msg);
if (SOURCE_JD_LOGISTICS_PUSH.equals(source) || SOURCE_REDIS_WAYBILL.equals(source)) {
return;
}
if (!notifyWecom) {
return;
}
try {
wxSendGoofishNotifyClient.notifyGoofishEvent(orderNo, eventType, source, msg);
} catch (Exception ex) {
log.debug("闲鱼订单事件 wxSend 通知跳过 err={}", ex.toString());
}
}
/**
* 合并摘要前后对比:订单状态/退款状态、平台运单与快递、本地运单。
*
* @param beforeSnap 合并前从 row 拷贝的跟踪字段快照(可不含 id
* @param afterRow 合并并 apply 后的 row
*/
public void logSummaryMergeDiff(ErpGoofishOrder beforeSnap, ErpGoofishOrder afterRow, String source) {
if (afterRow == null || afterRow.getId() == null) {
return;
}
RowSnap b = RowSnap.from(beforeSnap);
RowSnap a = RowSnap.from(afterRow);
boolean refundDiff = !Objects.equals(b.refundStatus, a.refundStatus);
boolean orderDiff = !Objects.equals(b.orderStatus, a.orderStatus);
List<String> orderParts = new ArrayList<>();
if (orderDiff) {
orderParts.add("订单状态 " + GoofishStatusLabels.orderStatusChangeForNotify(b.orderStatus, a.orderStatus));
}
if (refundDiff) {
orderParts.add("退款状态 " + GoofishStatusLabels.refundStatusChange(b.refundStatus, a.refundStatus));
}
if (!orderParts.isEmpty()) {
boolean notifyWecom = refundDiff || (orderDiff
&& GoofishStatusLabels.isWxNotifiableOrderStatusChange(b.orderStatus, a.orderStatus));
append(afterRow.getId(), afterRow.getAppKey(), afterRow.getOrderNo(), TYPE_ORDER, source,
String.join("", orderParts), notifyWecom);
}
List<String> logParts = new ArrayList<>();
if (!eqStr(b.detailWaybillNo, a.detailWaybillNo)) {
logParts.add("平台运单 " + str(b.detailWaybillNo) + "" + str(a.detailWaybillNo));
}
if (!eqStr(b.detailExpressCode, a.detailExpressCode)) {
logParts.add("快递编码 " + str(b.detailExpressCode) + "" + str(a.detailExpressCode));
}
if (!eqStr(b.detailExpressName, a.detailExpressName)) {
logParts.add("快递名称 " + str(b.detailExpressName) + "" + str(a.detailExpressName));
}
if (!eqStr(b.localWaybillNo, a.localWaybillNo)) {
logParts.add("本地运单 " + str(b.localWaybillNo) + "" + str(a.localWaybillNo));
}
if (!logParts.isEmpty()) {
append(afterRow.getId(), afterRow.getAppKey(), afterRow.getOrderNo(), TYPE_LOGISTICS, source,
String.join("", logParts));
}
}
private static String str(Object o) {
return o == null ? "null" : String.valueOf(o);
}
private static boolean eqStr(String x, String y) {
String a = x == null ? "" : x.trim();
String b = y == null ? "" : y.trim();
return Objects.equals(a, b);
}
private static final class RowSnap {
Integer orderStatus;
Integer refundStatus;
String detailWaybillNo;
String detailExpressCode;
String detailExpressName;
String localWaybillNo;
static RowSnap from(ErpGoofishOrder r) {
RowSnap s = new RowSnap();
if (r == null) {
return s;
}
s.orderStatus = r.getOrderStatus();
s.refundStatus = r.getRefundStatus();
s.detailWaybillNo = r.getDetailWaybillNo();
s.detailExpressCode = r.getDetailExpressCode();
s.detailExpressName = r.getDetailExpressName();
s.localWaybillNo = r.getLocalWaybillNo();
return s;
}
}
}

View File

@@ -0,0 +1,125 @@
package com.ruoyi.jarvis.service.goofish;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* 闲管家开放平台:订单状态、退款状态中文说明(与 Apifox 订单列表 schema 一致)
*/
public final class GoofishStatusLabels {
private static final Map<Integer, String> ORDER = new HashMap<>();
private static final Map<Integer, String> REFUND = new HashMap<>();
static {
ORDER.put(11, "待付款");
ORDER.put(12, "待发货");
ORDER.put(21, "已发货");
ORDER.put(22, "已完成");
ORDER.put(23, "已退款");
ORDER.put(24, "已关闭");
REFUND.put(0, "未申请退款");
REFUND.put(1, "待商家处理");
REFUND.put(2, "待买家退货");
REFUND.put(3, "待商家收货");
REFUND.put(4, "退款关闭");
REFUND.put(5, "退款成功");
REFUND.put(6, "已拒绝退款");
REFUND.put(8, "待确认退货地址");
}
private GoofishStatusLabels() {
}
/** 仅中文;未知时返回 null */
public static String orderStatusLabel(Integer s) {
if (s == null) {
return null;
}
return ORDER.get(s);
}
/** 仅中文;未知时返回 null */
public static String refundStatusLabel(Integer s) {
if (s == null) {
return null;
}
return REFUND.get(s);
}
/**
* 用于日志/企微:优先中文,未知时带码便于排查
*/
public static String orderStatusHuman(Integer s) {
String t = orderStatusLabel(s);
if (t != null) {
return t;
}
if (s == null) {
return "";
}
return "未识别状态(" + s + ")";
}
public static String refundStatusHuman(Integer s) {
String t = refundStatusLabel(s);
if (t != null) {
return t;
}
if (s == null) {
return "";
}
return "未识别状态(" + s + ")";
}
/**
* 变化摘要A→B两边均为人话
*/
public static String orderStatusChange(Integer from, Integer to) {
return orderStatusHuman(from) + "" + orderStatusHuman(to);
}
/**
* 快照/首行展示:待发阶段注明已付款语义(开放平台码 12 即待发货)。
*/
public static String orderStatusHumanForNotify(Integer s) {
if (Objects.equals(s, 12)) {
return "待发货(已付款)";
}
return orderStatusHuman(s);
}
/**
* 企微通知用变化文案:付款完成单独写「已付款(待发货)」。
*/
public static String orderStatusChangeForNotify(Integer from, Integer to) {
if (Objects.equals(from, 11) && Objects.equals(to, 12)) {
return orderStatusHuman(11) + " → 已付款(待发货)";
}
return orderStatusHuman(from) + "" + orderStatusHumanForNotify(to);
}
/**
* 是否与「付款、待发、在途、终态退款/完成/关闭」相关,从而值得推企微(排除已由 SHIP 覆盖的待发→已发)。
*/
public static boolean isWxNotifiableOrderStatusChange(Integer from, Integer to) {
if (Objects.equals(from, to)) {
return false;
}
if (Objects.equals(from, 12) && Objects.equals(to, 21)) {
return false;
}
int[] anchors = {11, 12, 21, 22, 23, 24};
for (int code : anchors) {
if (Objects.equals(from, code) || Objects.equals(to, code)) {
return true;
}
}
return false;
}
public static String refundStatusChange(Integer from, Integer to) {
return refundStatusHuman(from) + "" + refundStatusHuman(to);
}
}

View File

@@ -0,0 +1,321 @@
package com.ruoyi.jarvis.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.jarvis.config.JarvisGoofishProperties;
import com.ruoyi.jarvis.domain.ErpGoofishOrder;
import com.ruoyi.jarvis.domain.ErpGoofishOrderEventLog;
import com.ruoyi.jarvis.domain.ErpGoofishOrderEventLogQuery;
import com.ruoyi.jarvis.domain.ErpOpenConfig;
import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.dto.GoofishNotifyMessage;
import com.ruoyi.jarvis.mapper.ErpGoofishOrderEventLogMapper;
import com.ruoyi.jarvis.mapper.ErpGoofishOrderMapper;
import com.ruoyi.jarvis.service.IErpGoofishOrderService;
import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.IErpOpenConfigService;
import com.ruoyi.jarvis.service.goofish.GoofishNotifyAsyncFacade;
import com.ruoyi.jarvis.service.goofish.GoofishOrderChangeLogger;
import com.ruoyi.jarvis.service.goofish.GoofishOrderPipeline;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.utils.DateUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
@Service
public class ErpGoofishOrderServiceImpl implements IErpGoofishOrderService {
private static final Logger log = LoggerFactory.getLogger(ErpGoofishOrderServiceImpl.class);
@Autowired
private ObjectProvider<RocketMQTemplate> rocketMQTemplate;
@Resource
private JarvisGoofishProperties goofishProperties;
@Resource
private GoofishNotifyAsyncFacade goofishNotifyAsyncFacade;
@Resource
private GoofishOrderPipeline goofishOrderPipeline;
@Resource
private ErpGoofishOrderMapper erpGoofishOrderMapper;
@Resource
private ErpGoofishOrderEventLogMapper erpGoofishOrderEventLogMapper;
@Resource
private IErpOpenConfigService erpOpenConfigService;
@Resource
private GoofishOrderChangeLogger goofishOrderChangeLogger;
@Resource
private IJDOrderService jdOrderService;
@Override
public void publishOrProcessNotify(String appid, Long timestamp, JSONObject body) {
RocketMQTemplate mq = rocketMQTemplate.getIfAvailable();
if (mq != null) {
GoofishNotifyMessage m = new GoofishNotifyMessage();
m.setAppid(appid);
m.setTimestamp(timestamp);
m.setBody(body.toJSONString());
mq.syncSend(goofishProperties.getMqTopic(), JSON.toJSONString(m));
} else {
goofishNotifyAsyncFacade.afterNotify(appid, body);
}
}
@Override
public void asyncPipelineAfterNotify(String appid, JSONObject notifyBody) {
goofishOrderPipeline.runFullPipeline(appid, notifyBody);
}
@Override
public List<ErpGoofishOrder> selectList(ErpGoofishOrder query) {
return erpGoofishOrderMapper.selectList(query);
}
@Override
public ErpGoofishOrder selectById(Long id) {
return erpGoofishOrderMapper.selectById(id);
}
@Override
public int pullOrdersForAppKey(String appKey, int lookbackHours) {
return goofishOrderPipeline.pullForAppKey(appKey, lookbackHours);
}
@Override
public int pullAllEnabled(int lookbackHours) {
int total = 0;
List<ErpOpenConfig> cfgs = erpOpenConfigService.selectEnabledOrderBySort();
if (cfgs == null) {
return 0;
}
for (ErpOpenConfig c : cfgs) {
if (c.getAppKey() == null) {
continue;
}
total += goofishOrderPipeline.pullForAppKey(c.getAppKey(), lookbackHours);
}
return total;
}
@Override
public int pullOrdersForAppKeyFullHistory(String appKey) {
return goofishOrderPipeline.pullForAppKeyFullHistory(appKey);
}
@Override
public int pullAllEnabledFullHistory() {
int total = 0;
List<ErpOpenConfig> cfgs = erpOpenConfigService.selectEnabledOrderBySort();
if (cfgs == null) {
return 0;
}
for (ErpOpenConfig c : cfgs) {
if (c.getAppKey() == null) {
continue;
}
total += goofishOrderPipeline.pullForAppKeyFullHistory(c.getAppKey());
}
return total;
}
@Override
public void refreshDetail(Long id) {
ErpGoofishOrder row = erpGoofishOrderMapper.selectById(id);
if (row != null) {
goofishOrderPipeline.refreshDetail(row);
}
}
@Override
public void retryShip(Long id) {
erpGoofishOrderMapper.resetShipForRetry(id);
ErpGoofishOrder row = erpGoofishOrderMapper.selectById(id);
if (row != null) {
goofishOrderPipeline.refreshDetail(row);
goofishOrderPipeline.tryLinkJdOrder(row);
goofishOrderPipeline.syncWaybillFromRedis(row);
goofishOrderPipeline.tryAutoShip(row, true);
}
}
@Override
public int syncWaybillAndTryShipBatch(int limit) {
return goofishOrderPipeline.syncWaybillAndTryShipBatch(limit);
}
@Override
public void applyListOrNotifyItem(String appKey, JSONObject item, String lastNotifyJson) {
goofishOrderPipeline.applyListOrNotifyItem(appKey, item, lastNotifyJson);
}
@Override
public void notifyJdWaybillReady(Long jdOrderId) {
if (jdOrderId == null) {
return;
}
JDOrder jd = jdOrderService.selectJDOrderById(jdOrderId);
LinkedHashSet<Long> processedGoofishPk = new LinkedHashSet<>();
ErpGoofishOrder queryByJd = new ErpGoofishOrder();
queryByJd.setJdOrderId(jdOrderId);
List<ErpGoofishOrder> linked = erpGoofishOrderMapper.selectList(queryByJd);
if (linked != null) {
for (ErpGoofishOrder ref : linked) {
processGoofishWaybillSyncAfterJdReady(ref.getId(), processedGoofishPk);
}
}
if (jd != null && org.springframework.util.StringUtils.hasText(jd.getThirdPartyOrderNo())
&& isFxianyuDistributionMark(jd.getDistributionMark())) {
List<ErpGoofishOrder> byNo =
erpGoofishOrderMapper.selectByGoofishOrderNo(jd.getThirdPartyOrderNo().trim());
if (byNo != null) {
for (ErpGoofishOrder ref : byNo) {
ErpGoofishOrder full = erpGoofishOrderMapper.selectById(ref.getId());
if (full == null) {
continue;
}
if (full.getJdOrderId() != null && !full.getJdOrderId().equals(jdOrderId)) {
log.warn(
"闲鱼单 {} 已关联本地 jd_order_id={},与本次京东单行主键 {} 不一致,跳过补绑",
full.getOrderNo(), full.getJdOrderId(), jdOrderId);
continue;
}
if (full.getJdOrderId() == null) {
attachGoofishToJdOrder(full.getId(), jdOrderId);
}
processGoofishWaybillSyncAfterJdReady(ref.getId(), processedGoofishPk);
}
}
}
}
/** 单次:读库、同步 Redis 运单(含镜像 key、尽力自动发货 */
private void processGoofishWaybillSyncAfterJdReady(Long erpGoofishPk,
LinkedHashSet<Long> processedGoofishPk) {
if (erpGoofishPk == null || processedGoofishPk.contains(erpGoofishPk)) {
return;
}
ErpGoofishOrder full = erpGoofishOrderMapper.selectById(erpGoofishPk);
if (full == null) {
return;
}
goofishOrderPipeline.syncWaybillFromRedis(full);
goofishOrderPipeline.tryAutoShip(full);
processedGoofishPk.add(erpGoofishPk);
}
private void attachGoofishToJdOrder(long erpGoofishPk, long jdOrderDbId) {
ErpGoofishOrder p = new ErpGoofishOrder();
p.setId(erpGoofishPk);
p.setJdOrderId(jdOrderDbId);
p.setUpdateTime(DateUtils.getNowDate());
erpGoofishOrderMapper.update(p);
}
private static boolean isFxianyuDistributionMark(String distributionMark) {
if (!org.springframework.util.StringUtils.hasText(distributionMark)) {
return false;
}
String m = distributionMark.trim();
return m.startsWith("F") || m.contains("\u95f2\u9c7c");
}
@Override
public boolean hasLinkedGoofishOrder(Long jdOrderId) {
if (jdOrderId == null) {
return false;
}
ErpGoofishOrder query = new ErpGoofishOrder();
query.setJdOrderId(jdOrderId);
List<ErpGoofishOrder> list = erpGoofishOrderMapper.selectList(query);
return list != null && !list.isEmpty();
}
@Override
public void traceJdLogisticsPushForGoofish(Long jdOrderId, String waybillNo, String summary) {
if (jdOrderId == null || goofishOrderChangeLogger == null) {
return;
}
JDOrder jd = jdOrderService.selectJDOrderById(jdOrderId);
LinkedHashSet<Long> ids = resolveGoofishRowIdsForJdLogisticsEvent(jdOrderId, jd);
if (ids.isEmpty()) {
return;
}
String wb = waybillNo != null ? waybillNo.trim() : "";
String sum = summary != null ? summary : "";
String msg = "京东订单 " + jdOrderId + ",运单 " + wb + "" + sum;
if (msg.length() > 1000) {
msg = msg.substring(0, 999) + "";
}
for (Long oid : ids) {
ErpGoofishOrder row = erpGoofishOrderMapper.selectById(oid);
if (row == null || row.getId() == null) {
continue;
}
goofishOrderChangeLogger.append(row.getId(), row.getAppKey(), row.getOrderNo(),
GoofishOrderChangeLogger.TYPE_LOGISTICS, GoofishOrderChangeLogger.SOURCE_JD_LOGISTICS_PUSH, msg);
}
}
private LinkedHashSet<Long> resolveGoofishRowIdsForJdLogisticsEvent(Long jdOrderId, JDOrder jd) {
LinkedHashSet<Long> ids = new LinkedHashSet<>();
ErpGoofishOrder q = new ErpGoofishOrder();
q.setJdOrderId(jdOrderId);
List<ErpGoofishOrder> linked = erpGoofishOrderMapper.selectList(q);
if (linked != null) {
for (ErpGoofishOrder r : linked) {
if (r.getId() != null) {
ids.add(r.getId());
}
}
}
if (jd != null && org.springframework.util.StringUtils.hasText(jd.getThirdPartyOrderNo())
&& isFxianyuDistributionMark(jd.getDistributionMark())) {
List<ErpGoofishOrder> byNo =
erpGoofishOrderMapper.selectByGoofishOrderNo(jd.getThirdPartyOrderNo().trim());
if (byNo != null) {
for (ErpGoofishOrder r : byNo) {
if (r.getId() != null) {
ids.add(r.getId());
}
}
}
}
return ids;
}
@Override
public List<ErpGoofishOrderEventLog> listEventLogsByOrderId(Long orderId) {
if (orderId == null) {
return Collections.emptyList();
}
List<ErpGoofishOrderEventLog> list = erpGoofishOrderEventLogMapper.selectByOrderId(orderId);
return list != null ? list : Collections.emptyList();
}
@Override
public List<ErpGoofishOrderEventLog> selectEventLogList(ErpGoofishOrderEventLogQuery query) {
if (query == null) {
query = new ErpGoofishOrderEventLogQuery();
}
List<ErpGoofishOrderEventLog> list = erpGoofishOrderEventLogMapper.selectLogList(query);
return list != null ? list : Collections.emptyList();
}
}

View File

@@ -0,0 +1,63 @@
package com.ruoyi.jarvis.service.impl;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.jarvis.domain.ErpOpenConfig;
import com.ruoyi.jarvis.mapper.ErpOpenConfigMapper;
import com.ruoyi.jarvis.service.IErpOpenConfigService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
@Service
public class ErpOpenConfigServiceImpl implements IErpOpenConfigService {
@Resource
private ErpOpenConfigMapper erpOpenConfigMapper;
@Override
public ErpOpenConfig selectById(Long id) {
return erpOpenConfigMapper.selectById(id);
}
@Override
public ErpOpenConfig selectByAppKey(String appKey) {
if (appKey == null) {
return null;
}
return erpOpenConfigMapper.selectByAppKey(appKey.trim());
}
@Override
public ErpOpenConfig selectFirstEnabled() {
List<ErpOpenConfig> list = erpOpenConfigMapper.selectEnabledOrderBySort();
return list == null || list.isEmpty() ? null : list.get(0);
}
@Override
public List<ErpOpenConfig> selectList(ErpOpenConfig query) {
return erpOpenConfigMapper.selectList(query);
}
@Override
public List<ErpOpenConfig> selectEnabledOrderBySort() {
return erpOpenConfigMapper.selectEnabledOrderBySort();
}
@Override
public int insert(ErpOpenConfig row) {
row.setCreateTime(DateUtils.getNowDate());
return erpOpenConfigMapper.insert(row);
}
@Override
public int update(ErpOpenConfig row) {
row.setUpdateTime(DateUtils.getNowDate());
return erpOpenConfigMapper.update(row);
}
@Override
public int deleteById(Long id) {
return erpOpenConfigMapper.deleteById(id);
}
}

View File

@@ -7,7 +7,8 @@ import org.springframework.stereotype.Service;
import com.ruoyi.jarvis.mapper.ErpProductMapper; import com.ruoyi.jarvis.mapper.ErpProductMapper;
import com.ruoyi.jarvis.domain.ErpProduct; import com.ruoyi.jarvis.domain.ErpProduct;
import com.ruoyi.jarvis.service.IErpProductService; import com.ruoyi.jarvis.service.IErpProductService;
import com.ruoyi.erp.request.ERPAccount; import com.ruoyi.erp.request.IERPAccount;
import com.ruoyi.jarvis.service.erp.ErpAccountResolver;
import com.ruoyi.erp.request.ProductListQueryRequest; import com.ruoyi.erp.request.ProductListQueryRequest;
import com.ruoyi.erp.request.ProductDeleteRequest; import com.ruoyi.erp.request.ProductDeleteRequest;
import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONArray;
@@ -32,6 +33,9 @@ public class ErpProductServiceImpl implements IErpProductService
@Autowired @Autowired
private ErpProductMapper erpProductMapper; private ErpProductMapper erpProductMapper;
@Autowired
private ErpAccountResolver erpAccountResolver;
/** /**
* 查询闲鱼商品 * 查询闲鱼商品
* *
@@ -126,7 +130,7 @@ public class ErpProductServiceImpl implements IErpProductService
{ {
try { try {
// 解析ERP账号 // 解析ERP账号
ERPAccount account = resolveAccount(appid); IERPAccount account = erpAccountResolver.resolve(appid);
// 创建查询请求 // 创建查询请求
ProductListQueryRequest request = new ProductListQueryRequest(account); ProductListQueryRequest request = new ProductListQueryRequest(account);
@@ -319,7 +323,7 @@ public class ErpProductServiceImpl implements IErpProductService
int updated = 0; int updated = 0;
try { try {
ERPAccount account = resolveAccount(appid); IERPAccount account = erpAccountResolver.resolve(appid);
// 第一步:遍历所有页码,拉取并保存所有商品 // 第一步:遍历所有页码,拉取并保存所有商品
log.info("开始全量同步商品,账号:{}", appid); log.info("开始全量同步商品,账号:{}", appid);
@@ -463,18 +467,5 @@ public class ErpProductServiceImpl implements IErpProductService
} }
} }
/**
* 解析ERP账号
*/
private ERPAccount resolveAccount(String appid) {
if (appid != null && !appid.isEmpty()) {
for (ERPAccount account : ERPAccount.values()) {
if (account.getApiKey().equals(appid)) {
return account;
}
}
}
return ERPAccount.ACCOUNT_HUGE; // 默认账号
}
} }

View File

@@ -2,16 +2,21 @@ package com.ruoyi.jarvis.service.impl;
import com.ruoyi.jarvis.domain.OrderRows; import com.ruoyi.jarvis.domain.OrderRows;
import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.SuperAdmin;
import com.ruoyi.jarvis.domain.WeComShareLinkLogisticsJob;
import com.ruoyi.jarvis.service.IInstructionService; import com.ruoyi.jarvis.service.IInstructionService;
import com.ruoyi.jarvis.service.IOrderRowsService; import com.ruoyi.jarvis.service.IOrderRowsService;
import com.ruoyi.jarvis.service.IJDOrderService; import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.IProductJdConfigService; import com.ruoyi.jarvis.service.IProductJdConfigService;
import com.ruoyi.jarvis.service.IPhoneReplaceConfigService; import com.ruoyi.jarvis.service.IPhoneReplaceConfigService;
import com.ruoyi.jarvis.service.SuperAdminService; import com.ruoyi.jarvis.service.SuperAdminService;
import com.ruoyi.jarvis.service.IWeComShareLinkLogisticsJobService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime; import java.time.LocalTime;
@@ -49,12 +54,23 @@ public class InstructionServiceImpl implements IInstructionService {
@Resource @Resource
private com.ruoyi.jarvis.service.ITencentDocTokenService tencentDocTokenService; private com.ruoyi.jarvis.service.ITencentDocTokenService tencentDocTokenService;
@Resource @Resource
private IWeComShareLinkLogisticsJobService weComShareLinkLogisticsJobService;
@Resource
private com.ruoyi.jarvis.config.TencentDocConfig tencentDocConfig; private com.ruoyi.jarvis.config.TencentDocConfig tencentDocConfig;
@Resource @Resource
private com.ruoyi.common.core.redis.RedisCache redisCache; private com.ruoyi.common.core.redis.RedisCache redisCache;
@Autowired(required = false) @Autowired(required = false)
private com.ruoyi.jarvis.service.ITencentDocDelayedPushService tencentDocDelayedPushService; private com.ruoyi.jarvis.service.ITencentDocDelayedPushService tencentDocDelayedPushService;
/** 与 {@link com.ruoyi.jarvis.service.impl.WeComInboundServiceImpl#WE_COM_SUPER_USER_ID} 一致:该账号在京统计中视为全局视角 */
private static final String WE_COM_SUPER_GLOBAL_STATS_USER = "LinPingFan";
/** 与企微物流分享链解析一致 */
private static final Pattern JD_3CN = Pattern.compile("https://3\\.cn/[A-Za-z0-9\\-]+");
private static final int EXTERNAL_LOGISTICS_LIST_DAYS = 60;
private static final int EXTERNAL_LOGISTICS_LIST_LIMIT = 35;
// 录单模板(与 jd/JDUtil 中 WENAN_D 保持一致) // 录单模板(与 jd/JDUtil 中 WENAN_D 保持一致)
private static final String WENAN_D = "单:\n" + "{单号} \n" + "分销标记:{分销标记}\n" + "第三方单号:{第三方单号}\n" + "—————————\n" + "下单链接(必须用这个):\n" + "{链接}\n" + "下单地址(注意带分机):\n" + "{地址}\n" + "—————————\n" + "型号:{型号}\n" + "\n" + "下单人(需填):\n" + "\n" + "下单付款(注意核对):\n" + "\n" + "后返金额(注意核对):\n" + "\n" + "订单号(需填):\n" + "\n" + "物流链接(需填):\n" + "\n" + "备注(下单号码有变动/没法带分机号的写这里):\n" + "{单的备注}\n" + "—————————\n" + "京粉实际价格:不用填"; private static final String WENAN_D = "单:\n" + "{单号} \n" + "分销标记:{分销标记}\n" + "第三方单号:{第三方单号}\n" + "—————————\n" + "下单链接(必须用这个):\n" + "{链接}\n" + "下单地址(注意带分机):\n" + "{地址}\n" + "—————————\n" + "型号:{型号}\n" + "\n" + "下单人(需填):\n" + "\n" + "下单付款(注意核对):\n" + "\n" + "后返金额(注意核对):\n" + "\n" + "订单号(需填):\n" + "\n" + "物流链接(需填):\n" + "\n" + "备注(下单号码有变动/没法带分机号的写这里):\n" + "{单的备注}\n" + "—————————\n" + "京粉实际价格:不用填";
@@ -70,6 +86,11 @@ public class InstructionServiceImpl implements IInstructionService {
@Override @Override
public List<String> execute(String command, boolean forceGenerate, boolean isFromConsole) { public List<String> execute(String command, boolean forceGenerate, boolean isFromConsole) {
return execute(command, forceGenerate, isFromConsole, null);
}
@Override
public List<String> execute(String command, boolean forceGenerate, boolean isFromConsole, String wecomUserId) {
// 存储接收的消息到Redis队列 // 存储接收的消息到Redis队列
storeMessageToRedis("instruction:request", command); storeMessageToRedis("instruction:request", command);
@@ -87,7 +108,7 @@ public class InstructionServiceImpl implements IInstructionService {
// 一级命令分流:京系(统计/订单)、录单/慢单、转链/礼金… // 一级命令分流:京系(统计/订单)、录单/慢单、转链/礼金…
if (input.startsWith("") || menuKeywords().contains(input)) { if (input.startsWith("") || menuKeywords().contains(input)) {
result = Collections.singletonList(handleJingFen(input.replaceFirst("^京", ""))); result = Collections.singletonList(handleJingFen(input.replaceFirst("^京", ""), wecomUserId));
} }
// TF/H/生/拼多多 生成类指令 // TF/H/生/拼多多 生成类指令
else if (input.startsWith("TF")) { else if (input.startsWith("TF")) {
@@ -118,6 +139,77 @@ public class InstructionServiceImpl implements IInstructionService {
return result; return result;
} }
/**
* 「京」统计/订单所用京粉订单数据源:企微成员按超级管理员绑定 unionId全局视角排除「不参与订单统计」联盟。
*/
private static final class JingStatsScope {
private final List<OrderRows> rows;
/** 可为 null禁止访问统计不参与统计等 */
private final String denyMessage;
/** 回复前缀,标明当前统计归属 */
private final String replyPrefix;
private JingStatsScope(List<OrderRows> rows, String denyMessage, String replyPrefix) {
this.rows = rows != null ? rows : Collections.emptyList();
this.denyMessage = denyMessage;
this.replyPrefix = replyPrefix != null ? replyPrefix : "";
}
static JingStatsScope denied(String msg) {
return new JingStatsScope(Collections.emptyList(), msg, "");
}
static JingStatsScope ok(List<OrderRows> rows, String replyPrefix) {
return new JingStatsScope(rows, null, replyPrefix);
}
}
private List<Long> buildExcludeUnionIdsForStats() {
List<Long> excludeUnionIds = new ArrayList<>();
List<SuperAdmin> superAdminList = superAdminService.selectSuperAdminList(null);
if (superAdminList != null) {
for (SuperAdmin admin : superAdminList) {
if (admin.getIsCount() != null && admin.getIsCount() == 0 && admin.getUnionId() != null && !admin.getUnionId().trim().isEmpty()) {
try {
excludeUnionIds.add(Long.parseLong(admin.getUnionId().trim()));
} catch (NumberFormatException ignored) {
}
}
}
}
return excludeUnionIds;
}
private JingStatsScope resolveJingStatsScope(String wecomUserId) {
if (wecomUserId == null || wecomUserId.trim().isEmpty()
|| WE_COM_SUPER_GLOBAL_STATS_USER.equals(wecomUserId.trim())) {
List<OrderRows> all = orderRowsService.selectOrderRowsListWithFilter(new OrderRows(), null, null, buildExcludeUnionIdsForStats());
return JingStatsScope.ok(all, "");
}
String wxUser = wecomUserId.trim();
SuperAdmin sa = superAdminService.selectSuperAdminByWecomUserId(wxUser);
if (sa == null) {
return JingStatsScope.ok(Collections.emptyList(), "");
}
if (sa.getIsCount() != null && sa.getIsCount() == 0) {
return JingStatsScope.denied("「京统计」\n\n当前企微账号在后台「超级管理员」中标记为不参与订单统计无法使用统计与订单类京指令。\n如需开通请联系管理员。");
}
if (sa.getUnionId() == null || sa.getUnionId().trim().isEmpty()) {
return JingStatsScope.denied("「京统计」\n\n当前企微账号未绑定联盟ID无法匹配京粉订单。\n请在后台「超级管理员」中维护该账号对应行的联盟ID。");
}
try {
long uid = Long.parseLong(sa.getUnionId().trim());
OrderRows probe = new OrderRows();
probe.setUnionId(uid);
List<OrderRows> scoped = orderRowsService.selectOrderRowsListWithFilter(probe, null, null, Collections.emptyList());
String namePart = sa.getName() != null && !sa.getName().trim().isEmpty() ? sa.getName().trim() : "未命名";
String prefix = "【联盟 " + sa.getUnionId().trim() + " · " + namePart + "\n";
return JingStatsScope.ok(scoped, prefix);
} catch (NumberFormatException e) {
return JingStatsScope.denied("「京统计」\n\n联盟ID格式不正确请联系管理员检查「超级管理员」配置。");
}
}
/** /**
* 将消息存储到Redis队列最多保留100条 * 将消息存储到Redis队列最多保留100条
* @param key Redis键 * @param key Redis键
@@ -190,43 +282,55 @@ public class InstructionServiceImpl implements IInstructionService {
return new HashSet<>(Arrays.asList("菜单", "今日统计", "昨日统计", "三日统计", "七日统计", "一个月统计", "两个月统计", "三个月统计", "这个月统计", "上个月统计", "今日订单", "昨日订单", "七日订单", "总统计")); return new HashSet<>(Arrays.asList("菜单", "今日统计", "昨日统计", "三日统计", "七日统计", "一个月统计", "两个月统计", "三个月统计", "这个月统计", "上个月统计", "今日订单", "昨日订单", "七日订单", "总统计"));
} }
private String handleJingFen(String cmd) { private String handleJingFen(String cmd, String wecomUserId) {
String action = cmd.trim(); String action = cmd.trim();
if (action.isEmpty() || action.equals("菜单")) { if (action.isEmpty() || action.equals("菜单")) {
return jingMenu(); return jingMenu();
} }
// 取出所有订单(排除被删除/无效:这里沿用 OrderRowsService 的常规查询,必要时可增加过滤参数) if (action.startsWith("外物列表")) {
List<OrderRows> all = orderRowsService.selectOrderRowsList(new OrderRows()); String kw = action.substring("外物列表".length()).trim();
if (all == null) all = Collections.emptyList(); return textExternalShareLinkLogisticsList(kw);
}
if (action.startsWith("外物删")) {
String rest = action.substring("外物删".length()).trim();
return textExternalShareLinkLogisticsDelete(rest);
}
JingStatsScope scope = resolveJingStatsScope(wecomUserId);
if (scope.denyMessage != null) {
return scope.denyMessage;
}
List<OrderRows> all = scope.rows;
String header = scope.replyPrefix;
switch (action) { switch (action) {
case "今日统计": case "今日统计":
return statsText(filterByDays(all, 0), "今日统计"); return header + statsText(filterByDays(all, 0), "今日统计");
case "昨日统计": case "昨日统计":
return statsText(filterYesterday(all), "昨日统计"); return header + statsText(filterYesterday(all), "昨日统计");
case "三日统计": case "三日统计":
return statsText(filterByRange(all, 3), "三日统计"); return header + statsText(filterByRange(all, 3), "三日统计");
case "七日统计": case "七日统计":
return statsText(filterByRange(all, 7), "七日统计"); return header + statsText(filterByRange(all, 7), "七日统计");
case "一个月统计": case "一个月统计":
return statsText(filterByRange(all, 30), "一个月统计"); return header + statsText(filterByRange(all, 30), "一个月统计");
case "两个月统计": case "两个月统计":
return statsText(filterByRange(all, 60), "两个月统计"); return header + statsText(filterByRange(all, 60), "两个月统计");
case "三个月统计": case "三个月统计":
return statsText(filterByRange(all, 90), "三个月统计"); return header + statsText(filterByRange(all, 90), "三个月统计");
case "这个月统计": case "这个月统计":
return statsText(filterThisMonth(all), "这个月统计"); return header + statsText(filterThisMonth(all), "这个月统计");
case "上个月统计": case "上个月统计":
return statsText(filterLastMonth(all), "上个月统计"); return header + statsText(filterLastMonth(all), "上个月统计");
case "总统计": case "总统计":
return statsText(all, "总统计"); return header + statsText(all, "总统计");
case "今日订单": case "今日订单":
return listOrders(filterByDays(all, 0), "今日订单"); return header + listOrders(filterByDays(all, 0), "今日订单");
case "昨日订单": case "昨日订单":
return listOrders(filterYesterday(all), "昨日订单"); return header + listOrders(filterYesterday(all), "昨日订单");
case "七日订单": case "七日订单":
return listOrders(filterByRange(all, 7), "七日订单"); return header + listOrders(filterByRange(all, 7), "七日订单");
default: default:
// 高级命令违规N、SKU、搜索、JF… 此处按需扩展 // 高级命令违规N、SKU、搜索、JF… 此处按需扩展
if (action.startsWith("高级")) { if (action.startsWith("高级")) {
@@ -476,11 +580,11 @@ public class InstructionServiceImpl implements IInstructionService {
// ==================== 追加:按下单人分组统计 ==================== // ==================== 追加:按下单人分组统计 ====================
outputs.add("\n━━━━━━━ 按下单人统计 ━━━━━━━"); outputs.add("\n━━━━━━━ 按下单人统计 ━━━━━━━");
// 按下单人分组(过滤掉拍错退款) // 按下单人「前缀」分组:取第一个 "-" 之前为同组(如 凡-林、凡-淑玲 → 凡;陈文慧-林、陈文慧-666 → 陈文慧);无 "-" 则按完整下单人
Map<String, List<JDOrder>> byBuyer = filtered.stream() Map<String, List<JDOrder>> byBuyer = filtered.stream()
.filter(o -> o.getStatus() == null || !"拍错退款".equals(o.getStatus())) .filter(o -> o.getStatus() == null || !"拍错退款".equals(o.getStatus()))
.filter(o -> o.getBuyer() != null && !o.getBuyer().isEmpty()) .filter(o -> o.getBuyer() != null && !o.getBuyer().isEmpty())
.collect(Collectors.groupingBy(JDOrder::getBuyer)); .collect(Collectors.groupingBy(o -> buyerGroupKey(o.getBuyer())));
List<Map.Entry<String, List<JDOrder>>> buyerEntries = new ArrayList<>(byBuyer.entrySet()); List<Map.Entry<String, List<JDOrder>>> buyerEntries = new ArrayList<>(byBuyer.entrySet());
buyerEntries.sort(Comparator.comparing(en -> en.getKey() == null ? "" : en.getKey())); buyerEntries.sort(Comparator.comparing(en -> en.getKey() == null ? "" : en.getKey()));
@@ -1147,24 +1251,6 @@ public class InstructionServiceImpl implements IInstructionService {
stringRedisTemplate.opsForValue().set(orderNumberKey, orderNumberForDedup, 1, TimeUnit.DAYS); stringRedisTemplate.opsForValue().set(orderNumberKey, orderNumberForDedup, 1, TimeUnit.DAYS);
} }
// 第二重判断:地址 24 小时去重校验(白名单放行)
if (stringRedisTemplate != null) {
String addressKey = "address:" + address;
String existed = stringRedisTemplate.opsForValue().get(addressKey);
if (existed != null) {
if (!(existed.contains("李波") || existed.contains("吴胜硕") || existed.contains("小硕硕"))) {
// 如果强制生成,跳过地址重复检查
if (!forceGenerate) {
// 返回特殊错误码,前端会识别并弹出验证码
return "ERROR_CODE:ADDRESS_DUPLICATE\n此地址已经存在请勿重复生成订单";
}
// forceGenerate为true时跳过地址重复检查继续执行
}
}
// 只有在不强制生成或地址不存在时才设置Redis强制生成时也更新Redis记录
stringRedisTemplate.opsForValue().set(addressKey, address, 1, TimeUnit.DAYS);
}
String today = new java.text.SimpleDateFormat("yyyy-MM-dd ").format(new Date()); String today = new java.text.SimpleDateFormat("yyyy-MM-dd ").format(new Date());
String todayNoSpace = today.trim(); String todayNoSpace = today.trim();
String keyWithSpace = "order_count:" + today; // 带空格 String keyWithSpace = "order_count:" + today; // 带空格
@@ -1188,14 +1274,40 @@ public class InstructionServiceImpl implements IInstructionService {
StringBuilder out = new StringBuilder(); StringBuilder out = new StringBuilder();
int total = Math.max(1, num); int total = Math.max(1, num);
// 第二重:同日 + 型号 + 地址 槽位。重复提交视为「更新」——复用当日已分配序号,不递增全局 order_count强制生成则始终新占号并覆盖槽位。
String slotDigest = digestShengSlot(model, address);
String slotKey = "sheng_slot:" + todayNoSpace + ":" + slotDigest;
Integer reusedCounter = null;
if (stringRedisTemplate != null && !forceGenerate && total == 1) {
String slotVal = stringRedisTemplate.opsForValue().get(slotKey);
if (slotVal != null && !slotVal.isEmpty()) {
try {
int c = Integer.parseInt(slotVal.trim());
if (c >= 1) {
reusedCounter = c;
}
} catch (NumberFormatException ignore) {
reusedCounter = null;
}
}
}
int startCount = 1; int startCount = 1;
if (reusedCounter != null) {
startCount = reusedCounter;
if (stringRedisTemplate != null) { if (stringRedisTemplate != null) {
stringRedisTemplate.opsForValue().set(slotKey, String.valueOf(reusedCounter), 1, TimeUnit.DAYS);
}
} else if (stringRedisTemplate != null) {
try { try {
String s = stringRedisTemplate.opsForValue().get(redisKey); String s = stringRedisTemplate.opsForValue().get(redisKey);
int count = s != null ? Integer.parseInt(s) : 0; int count = s != null ? Integer.parseInt(s) : 0;
startCount = count + 1; startCount = count + 1;
int endCount = count + total; int endCount = count + total;
stringRedisTemplate.opsForValue().set(redisKey, String.valueOf(endCount), 30, TimeUnit.DAYS); stringRedisTemplate.opsForValue().set(redisKey, String.valueOf(endCount), 30, TimeUnit.DAYS);
if (total == 1) {
stringRedisTemplate.opsForValue().set(slotKey, String.valueOf(startCount), 1, TimeUnit.DAYS);
}
} catch (Exception ignore) { } catch (Exception ignore) {
} }
} }
@@ -1217,6 +1329,26 @@ public class InstructionServiceImpl implements IInstructionService {
return out.toString(); return out.toString();
} }
/**
* 同日「生」指令槽位键摘要:规范化后的 型号 + 地址,避免仅按地址误伤「同址不同型号」。
*/
private String digestShengSlot(String model, String address) {
String m = normalizeWhitespace(model == null ? "" : model);
String a = normalizeWhitespace(address == null ? "" : address);
String raw = m + "\u0001" + a;
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] d = md.digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(32);
for (byte b : d) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
return Integer.toHexString(Objects.hash(m, a));
}
}
/** /**
* 从分销标记中提取订单编号 * 从分销标记中提取订单编号
* 例如:从 "H-TF(10.10 腾锋 JY202510093195)" 中提取 "JY202510093195" * 例如:从 "H-TF(10.10 腾锋 JY202510093195)" 中提取 "JY202510093195"
@@ -1349,7 +1481,14 @@ public class InstructionServiceImpl implements IInstructionService {
if (mark == null) { if (mark == null) {
return false; return false;
} }
return "F".equalsIgnoreCase(mark.trim()); String t = mark.trim();
if (t.isEmpty()) {
return false;
}
if ("F".equalsIgnoreCase(t)) {
return true;
}
return t.length() >= 2 && t.regionMatches(true, 0, "F-", 0, 2);
} }
private static boolean isNewTemplateDanWriteSuccess(String primary) { private static boolean isNewTemplateDanWriteSuccess(String primary) {
@@ -2091,16 +2230,207 @@ public class InstructionServiceImpl implements IInstructionService {
}).collect(Collectors.toList()); }).collect(Collectors.toList());
} }
/**
* 企微「外部分享链物流」登记查询:用于发现同一备注、不同短链等重复登记。
*/
private String textExternalShareLinkLogisticsList(String remarkKeyword) {
if (weComShareLinkLogisticsJobService == null) {
return "「外物列表」\n\n服务未就绪。";
}
List<WeComShareLinkLogisticsJob> rows = weComShareLinkLogisticsJobService.selectRecentForInstruction(
remarkKeyword, EXTERNAL_LOGISTICS_LIST_DAYS, EXTERNAL_LOGISTICS_LIST_LIMIT);
if (rows == null || rows.isEmpty()) {
return "「外物列表」\n\n近 " + EXTERNAL_LOGISTICS_LIST_DAYS + " 天内无匹配记录。"
+ (remarkKeyword.isEmpty() ? "" : "\n关键词" + remarkKeyword + "");
}
Map<String, Long> remarkCount = rows.stream()
.map(j -> j.getUserRemark() != null ? j.getUserRemark().trim() : "")
.collect(Collectors.groupingBy(s -> s, Collectors.counting()));
StringBuilder sb = new StringBuilder();
sb.append("「外物列表」近").append(EXTERNAL_LOGISTICS_LIST_DAYS).append("天,最多")
.append(EXTERNAL_LOGISTICS_LIST_LIMIT).append("");
if (!remarkKeyword.isEmpty()) {
sb.append(",关键词「").append(remarkKeyword).append("");
}
sb.append("\n\n");
int i = 0;
for (WeComShareLinkLogisticsJob j : rows) {
i++;
String rk = j.getJobKey() != null ? j.getJobKey() : "";
String st = j.getStatus() != null ? j.getStatus() : "";
String rm = j.getUserRemark() != null ? j.getUserRemark().trim() : "";
if (rm.length() > 80) {
rm = rm.substring(0, 80) + "";
}
String url = j.getTrackingUrl() != null ? j.getTrackingUrl().trim() : "";
long dup = remarkCount.getOrDefault(j.getUserRemark() != null ? j.getUserRemark().trim() : "", 0L);
sb.append(i).append(". ").append(st);
if (dup > 1) {
sb.append(" ·本批同备注").append(dup).append("");
}
sb.append("\nkey=").append(rk);
sb.append("\n备注").append(rm.isEmpty() ? "(空)" : rm);
sb.append("\n链").append(url);
if (j.getCreateTime() != null) {
sb.append("\n时").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm")
.format(j.getCreateTime()));
}
sb.append("\n——————\n");
}
sb.append("删:京外物删 key\n或京外物删 后接换行写备注,最后一行写含 3.cn 的链接。");
return sb.toString();
}
/**
* 删除外部分享链物流任务行与后台物理删除一致Redis 中未消费项会因库无行而跳过)。
*/
private String textExternalShareLinkLogisticsDelete(String rest) {
if (weComShareLinkLogisticsJobService == null) {
return "「外物删」\n\n服务未就绪。";
}
if (rest == null || rest.isEmpty()) {
return "「外物删」\n\n用法\n1) 京外物删 <jobKey>(见「京外物列表」中的 key=\n"
+ "2) 京外物删\n<备注全文>\nhttps://3.cn/…";
}
String oneLine = rest.replace("\r\n", "\n").trim();
if (!oneLine.contains("\n") && looksLikeShareLinkJobKey(oneLine)) {
WeComShareLinkLogisticsJob existed = weComShareLinkLogisticsJobService.selectByJobKey(oneLine);
int n = weComShareLinkLogisticsJobService.deleteByJobKey(oneLine);
if (n <= 0) {
return "「外物删」\n\n未删除任何行jobKey 可能不存在):" + oneLine;
}
String hint = existed != null
? "\n备注" + shortenForReply(existed.getUserRemark(), 120)
+ "\n链" + nvl(existed.getTrackingUrl())
: "";
return "「外物删」\n\n已删除 " + n + " 条。" + hint;
}
ParsedRemarkAndUrl parsed = parseRemarkAnd3cnFromDeletePayload(rest);
if (parsed == null) {
return "「外物删」\n\n未能解析备注与 3.cn 链接。请多行发送:倒数第一个含 3.cn 的为链接,其上一行起为备注;"
+ "或单行:京外物删 <jobKey>";
}
int n = weComShareLinkLogisticsJobService.deleteByRemarkAndTrackingUrl(parsed.remark, parsed.trackingUrl);
if (n <= 0) {
return "「外物删」\n\n未找到完全匹配的行备注与短链需与登记一致含空格请对齐\n备注"
+ shortenForReply(parsed.remark, 200) + "\n链" + parsed.trackingUrl;
}
return "「外物删」\n\n已按备注+链接删除 " + n + " 条。";
}
private static boolean looksLikeShareLinkJobKey(String s) {
if (s == null) {
return false;
}
String t = s.trim();
return t.matches("^[a-fA-F0-9]{32}$") || t.matches("^tracebf\\d+$");
}
private static String shortenForReply(String s, int maxChars) {
if (s == null) {
return "";
}
String t = s.trim();
if (t.length() <= maxChars) {
return t;
}
return t.substring(0, maxChars) + "";
}
private static final class ParsedRemarkAndUrl {
final String remark;
final String trackingUrl;
ParsedRemarkAndUrl(String remark, String trackingUrl) {
this.remark = remark;
this.trackingUrl = trackingUrl;
}
}
private static ParsedRemarkAndUrl parseRemarkAnd3cnFromDeletePayload(String rest) {
if (rest == null) {
return null;
}
String normalized = rest.replace("\r\n", "\n").trim();
String[] lines = normalized.split("\n");
if (lines.length == 1) {
ParsedRemarkAndUrl one = tryParseRemarkAndUrlSingleLine(lines[0]);
if (one != null) {
return one;
}
}
int urlIndex = -1;
String canonicalUrl = null;
for (int i = lines.length - 1; i >= 0; i--) {
String u = extractJd3cnForInstruction(lines[i]);
if (u != null) {
urlIndex = i;
canonicalUrl = u;
break;
}
}
if (canonicalUrl == null || urlIndex < 0) {
return null;
}
StringBuilder rem = new StringBuilder();
for (int i = 0; i < urlIndex; i++) {
if (i > 0) {
rem.append('\n');
}
rem.append(lines[i].trim());
}
String remark = rem.toString().trim();
return new ParsedRemarkAndUrl(remark, canonicalUrl);
}
/** 单行「…备注… https://3.cn/…」 */
private static ParsedRemarkAndUrl tryParseRemarkAndUrlSingleLine(String line) {
if (line == null || line.isEmpty()) {
return null;
}
Matcher m = JD_3CN.matcher(line);
if (m.find()) {
String url = m.group();
String rem = line.substring(0, m.start()).trim();
return new ParsedRemarkAndUrl(rem, url);
}
Matcher m2 = Pattern.compile("http://3\\.cn/[A-Za-z0-9\\-]+").matcher(line);
if (m2.find()) {
String url = m2.group().replace("http://", "https://");
String rem = line.substring(0, m2.start()).trim();
return new ParsedRemarkAndUrl(rem, url);
}
return null;
}
private static String extractJd3cnForInstruction(String text) {
if (text == null) {
return null;
}
Matcher m = JD_3CN.matcher(text);
if (m.find()) {
return m.group();
}
Matcher m2 = Pattern.compile("http://3\\.cn/[A-Za-z0-9\\-]+").matcher(text);
if (m2.find()) {
return m2.group().replace("http://", "https://");
}
return null;
}
// ===== 工具 ===== // ===== 工具 =====
private String jingMenu() { private String jingMenu() {
return "「京粉 · 菜单」\n\n" return "「京粉 · 菜单」\n\n"
+ "企微/机器人前请加「京」,例如:京今日统计\n\n" + "企微/机器人前请加「京」,例如:京今日统计\n"
+ "说明企微内统计仅含当前账号在「超级管理员」绑定的联盟ID标记为不参与订单统计的联盟不会在全局汇总中出现与后台京粉订单列表统计一致\n\n"
+ "—— 统计 ——\n" + "—— 统计 ——\n"
+ "今日统计、昨日统计、三日统计、七日统计\n" + "今日统计、昨日统计、三日统计、七日统计\n"
+ "一个月统计、两个月统计、三个月统计\n" + "一个月统计、两个月统计、三个月统计\n"
+ "这个月统计、上个月统计、总统计\n\n" + "这个月统计、上个月统计、总统计\n\n"
+ "—— 订单 ——\n" + "—— 订单 ——\n"
+ "今日订单、昨日订单、七日订单\n\n" + "今日订单、昨日订单、七日订单\n\n"
+ "—— 外部分享链物流 ——\n"
+ "京外物列表 [关键词]、京外物删(见列表说明)\n\n"
+ "发「京」单独或「京菜单」可再次打开本列表。"; + "发「京」单独或「京菜单」可再次打开本列表。";
} }
@@ -2111,6 +2441,7 @@ public class InstructionServiceImpl implements IInstructionService {
+ "· 京今日统计 / 京昨日统计 / 京七日统计 …\n" + "· 京今日统计 / 京昨日统计 / 京七日统计 …\n"
+ "· 京今日订单 / 京昨日订单 / 京七日订单\n" + "· 京今日订单 / 京昨日订单 / 京七日订单\n"
+ "· 慢搜 关键词、慢查 关键词(录单库模糊查询)\n" + "· 慢搜 关键词、慢查 关键词(录单库模糊查询)\n"
+ "· 京外物列表 / 京外物删 — 企微 3.cn 分享链登记查询与删除\n"
+ "· 录单20250101-20250107 或 录单昨日|三日|七日(导出)\n\n" + "· 录单20250101-20250107 或 录单昨日|三日|七日(导出)\n\n"
+ "说明:转链、礼金等请使用系统内「一键转链」页面。"; + "说明:转链、礼金等请使用系统内「一键转链」页面。";
} }
@@ -2188,6 +2519,25 @@ public class InstructionServiceImpl implements IInstructionService {
return ""; return "";
} }
/**
* 慢单「按下单人统计」分组键:第一个 "-" 前的前缀视为同一对象;无 "-" 时用整段下单人。
*/
private static String buyerGroupKey(String buyer) {
if (buyer == null) {
return "";
}
String t = buyer.trim();
if (t.isEmpty()) {
return "";
}
int idx = t.indexOf('-');
if (idx > 0) {
String prefix = t.substring(0, idx).trim();
return prefix.isEmpty() ? t : prefix;
}
return t;
}
private boolean contains(Object field, String kwLower) { private boolean contains(Object field, String kwLower) {
if (field == null) return false; if (field == null) return false;
return String.valueOf(field).toLowerCase(Locale.ROOT).contains(kwLower); return String.valueOf(field).toLowerCase(Locale.ROOT).contains(kwLower);

View File

@@ -2,6 +2,7 @@ package com.ruoyi.jarvis.service.impl;
import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.ProductJdConfig; import com.ruoyi.jarvis.domain.ProductJdConfig;
import com.ruoyi.jarvis.mapper.JDOrderMapper;
import com.ruoyi.jarvis.service.IJDOrderProfitService; import com.ruoyi.jarvis.service.IJDOrderProfitService;
import com.ruoyi.jarvis.service.IProductJdConfigService; import com.ruoyi.jarvis.service.IProductJdConfigService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -9,15 +10,23 @@ import org.springframework.stereotype.Service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Service @Service
public class JDOrderProfitServiceImpl implements IJDOrderProfitService { public class JDOrderProfitServiceImpl implements IJDOrderProfitService {
private static final double XIANYU_NET_FACTOR = 0.984; private static final double XIANYU_NET_FACTOR = 0.984;
private static final double MONEY_EPS = 0.009;
@Autowired @Autowired
private IProductJdConfigService productJdConfigService; private IProductJdConfigService productJdConfigService;
@Autowired
private JDOrderMapper jdOrderMapper;
@Override @Override
public void recalculate(JDOrder order) { public void recalculate(JDOrder order) {
if (order == null) { if (order == null) {
@@ -28,17 +37,29 @@ public class JDOrderProfitServiceImpl implements IJDOrderProfitService {
boolean sellingLocked = order.getSellingPriceManual() != null && order.getSellingPriceManual() == 1; boolean sellingLocked = order.getSellingPriceManual() != null && order.getSellingPriceManual() == 1;
if ("H-TF".equals(mark)) { if ("H-TF".equals(mark)) {
order.setSellingPriceType(null);
order.setSellingPrice(null);
if (!profitLocked) { if (!profitLocked) {
// 与 F 单一致:利润 = 对客实收 (下单付款 后返)。列表未填售价时默认直款并从型号配置取价。
String type = order.getSellingPriceType();
if (type == null || type.isEmpty()) {
order.setSellingPriceType("direct");
}
if (!sellingLocked && order.getSellingPrice() == null) {
fillSellingPriceFromConfig(order);
}
String effType = order.getSellingPriceType();
Double sp = order.getSellingPrice();
if (effType != null && !effType.isEmpty() && sp != null) {
computeProfitForF(order);
} else {
String buyer = order.getBuyer(); String buyer = order.getBuyer();
boolean fan = buyer != null && buyer.trim().startsWith("凡-"); boolean fan = buyer != null && buyer.trim().startsWith("凡-");
order.setProfit(fan ? 65.0 : 15.0); order.setProfit(fan ? 65.0 : 15.0);
} }
}
return; return;
} }
if ("F".equals(mark)) { if ("F".equals(mark) || mark.startsWith("F-")) {
if (!sellingLocked) { if (!sellingLocked) {
fillSellingPriceFromConfig(order); fillSellingPriceFromConfig(order);
} }
@@ -98,7 +119,61 @@ public class JDOrderProfitServiceImpl implements IJDOrderProfitService {
order.setProfit(null); order.setProfit(null);
return; return;
} }
order.setProfit(BigDecimal.valueOf(netReceipt - pay - rebate) // 成本 = 下单付款 - 后返金额;利润 = 对客实收(直款=售价,闲鱼=扣点后的到账)- 成本
double cost = BigDecimal.valueOf(pay).subtract(BigDecimal.valueOf(rebate))
.setScale(2, RoundingMode.HALF_UP).doubleValue();
order.setProfit(BigDecimal.valueOf(netReceipt - cost)
.setScale(2, RoundingMode.HALF_UP).doubleValue()); .setScale(2, RoundingMode.HALF_UP).doubleValue());
} }
@Override
public int syncAutoProfitIfChanged(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return 0;
}
Set<Long> seen = new HashSet<>();
int updated = 0;
for (Long id : ids) {
if (id == null || !seen.add(id)) {
continue;
}
JDOrder order = jdOrderMapper.selectJDOrderById(id);
if (order == null) {
continue;
}
if (order.getProfitManual() != null && order.getProfitManual() == 1) {
continue;
}
String oldType = order.getSellingPriceType();
Double oldSp = order.getSellingPrice();
Double oldProfit = order.getProfit();
recalculate(order);
if (sameNullableString(oldType, order.getSellingPriceType())
&& sameMoney(oldSp, order.getSellingPrice())
&& sameMoney(oldProfit, order.getProfit())) {
continue;
}
order.getParams().put("applyProfitFields", Boolean.TRUE);
updated += jdOrderMapper.updateJDOrder(order);
}
return updated;
}
private static boolean sameNullableString(String a, String b) {
String x = a == null ? "" : a.trim();
String y = b == null ? "" : b.trim();
return Objects.equals(x, y);
}
private static boolean sameMoney(Double a, Double b) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
return Math.abs(a - b) < MONEY_EPS;
}
} }

View File

@@ -1,6 +1,7 @@
package com.ruoyi.jarvis.service.impl; package com.ruoyi.jarvis.service.impl;
import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.dto.QuickRecordModelOption;
import com.ruoyi.jarvis.mapper.JDOrderMapper; import com.ruoyi.jarvis.mapper.JDOrderMapper;
import com.ruoyi.jarvis.service.IJDOrderProfitService; import com.ruoyi.jarvis.service.IJDOrderProfitService;
import com.ruoyi.jarvis.service.IJDOrderService; import com.ruoyi.jarvis.service.IJDOrderService;
@@ -81,6 +82,11 @@ public class JDOrderServiceImpl implements IJDOrderService {
public List<JDOrder> selectJDOrderListByDistributionMarkFOrPDD() { public List<JDOrder> selectJDOrderListByDistributionMarkFOrPDD() {
return jdOrderMapper.selectJDOrderListByDistributionMarkFOrPDD(); return jdOrderMapper.selectJDOrderListByDistributionMarkFOrPDD();
} }
@Override
public List<QuickRecordModelOption> selectQuickRecordModelOptions() {
return jdOrderMapper.selectQuickRecordModelOptions();
}
} }

View File

@@ -6,8 +6,10 @@ import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.WeComShareLinkLogisticsJob; import com.ruoyi.jarvis.domain.WeComShareLinkLogisticsJob;
import com.ruoyi.jarvis.mapper.WeComShareLinkLogisticsJobMapper; import com.ruoyi.jarvis.mapper.WeComShareLinkLogisticsJobMapper;
import com.ruoyi.jarvis.service.IErpGoofishOrderService;
import com.ruoyi.jarvis.service.ILogisticsService; import com.ruoyi.jarvis.service.ILogisticsService;
import com.ruoyi.jarvis.service.IJDOrderService; import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.goofish.GoofishOrderPipeline;
import com.ruoyi.system.service.ISysConfigService; import com.ruoyi.system.service.ISysConfigService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -20,13 +22,16 @@ import javax.annotation.Resource;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Calendar; import java.util.Calendar;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.util.DigestUtils; import org.springframework.util.DigestUtils;
@@ -47,7 +52,7 @@ public class LogisticsServiceImpl implements ILogisticsService {
private static final String REDIS_ADHOC_RECONCILE_ENQUEUE_PREFIX = "logistics:adhoc:reconcile:enqueue:"; private static final String REDIS_ADHOC_RECONCILE_ENQUEUE_PREFIX = "logistics:adhoc:reconcile:enqueue:";
private static final int ADHOC_RECONCILE_ROW_LIMIT = 200; private static final int ADHOC_RECONCILE_ROW_LIMIT = 200;
private static final long ADHOC_RECONCILE_ENQUEUE_LOCK_SECONDS = 8 * 60; private static final long ADHOC_RECONCILE_ENQUEUE_LOCK_SECONDS = 8 * 60;
/** 单次队列项最多重新入队次数(约对应多天 × 每 10 分钟一轮) */ /** 单次队列项最多重新入队次数(约对应多天 × 每 15 分钟一轮) */
private static final int ADHOC_MAX_REQUEUE_ATTEMPTS = 500; private static final int ADHOC_MAX_REQUEUE_ATTEMPTS = 500;
private static final String PUSH_URL = "https://wxts.van333.cn/wx/send/pdd"; private static final String PUSH_URL = "https://wxts.van333.cn/wx/send/pdd";
private static final String PUSH_TOKEN = "super_token_b62190c26"; private static final String PUSH_TOKEN = "super_token_b62190c26";
@@ -58,6 +63,10 @@ public class LogisticsServiceImpl implements ILogisticsService {
@Value("${jarvis.server.logistics.base-url:http://127.0.0.1:5001}") @Value("${jarvis.server.logistics.base-url:http://127.0.0.1:5001}")
private String logisticsBaseUrl; private String logisticsBaseUrl;
/** 逗号分隔的多个解析服务 base非空时优先于此列表轮询例如 http://10.0.0.1:5001,http://10.0.0.2:5001 */
@Value("${jarvis.server.logistics.base-urls:}")
private String logisticsBaseUrlsRaw;
@Value("${jarvis.server.logistics.fetch-path:/fetch_logistics}") @Value("${jarvis.server.logistics.fetch-path:/fetch_logistics}")
private String logisticsFetchPath; private String logisticsFetchPath;
@@ -70,8 +79,10 @@ public class LogisticsServiceImpl implements ILogisticsService {
@Resource @Resource
private WeComShareLinkLogisticsJobMapper weComShareLinkLogisticsJobMapper; private WeComShareLinkLogisticsJobMapper weComShareLinkLogisticsJobMapper;
private String externalApiUrlTemplate; /** 已规范化(无尾部斜杠)的物流解析服务 base 列表,至少一项 */
private String healthCheckUrl; private List<String> logisticsServiceBases = Collections.emptyList();
private String logisticsServiceBasesSummary = "";
private final AtomicInteger logisticsBaseRoundRobin = new AtomicInteger(0);
@Resource @Resource
private StringRedisTemplate stringRedisTemplate; private StringRedisTemplate stringRedisTemplate;
@@ -82,12 +93,84 @@ public class LogisticsServiceImpl implements ILogisticsService {
@Resource @Resource
private IJDOrderService jdOrderService; private IJDOrderService jdOrderService;
@Resource
private IErpGoofishOrderService erpGoofishOrderService;
@PostConstruct @PostConstruct
public void init() { public void init() {
externalApiUrlTemplate = logisticsBaseUrl + logisticsFetchPath + "?tracking_url="; List<String> list = new ArrayList<>();
healthCheckUrl = logisticsBaseUrl + logisticsHealthPath; if (StringUtils.hasText(logisticsBaseUrlsRaw)) {
logger.info("物流服务地址已初始化: {}", externalApiUrlTemplate); for (String part : logisticsBaseUrlsRaw.split(",")) {
logger.info("物流服务健康检查地址已初始化: {}", healthCheckUrl); String n = normalizeLogisticsBaseUrl(part);
if (StringUtils.hasText(n)) {
list.add(n);
}
}
}
if (list.isEmpty()) {
list.add(normalizeLogisticsBaseUrl(logisticsBaseUrl));
}
logisticsServiceBases = Collections.unmodifiableList(list);
logisticsServiceBasesSummary = String.join(", ", logisticsServiceBases);
logger.info("物流服务实例 {} 个(轮询): {}", logisticsServiceBases.size(), logisticsServiceBasesSummary);
}
private static String normalizeLogisticsBaseUrl(String base) {
if (!StringUtils.hasText(base)) {
return "";
}
String t = base.trim();
while (t.endsWith("/")) {
t = t.substring(0, t.length() - 1);
}
return t;
}
private String pickLogisticsBaseUrl() {
int n = logisticsServiceBases.size();
if (n == 0) {
return normalizeLogisticsBaseUrl(logisticsBaseUrl);
}
if (n == 1) {
return logisticsServiceBases.get(0);
}
int idx = Math.floorMod(logisticsBaseRoundRobin.getAndIncrement(), n);
return logisticsServiceBases.get(idx);
}
@Override
public String buildFetchLogisticsRequestUrl(String logisticsLink) {
String base = pickLogisticsBaseUrl();
try {
return base + logisticsFetchPath + "?tracking_url=" + URLEncoder.encode(logisticsLink, "UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}
/**
* 与原先单 URL 健康检查语义一致JSON 字段命中则通过;非 JSON 时仅当正文含 ok/healthy/success 子串则通过。
*/
private boolean isHealthResponseBodyHealthy(String healthResult) {
if (healthResult == null || healthResult.trim().isEmpty()) {
return false;
}
try {
JSONObject response = JSON.parseObject(healthResult);
if (response != null) {
String status = response.getString("status");
Boolean healthy = response.getBoolean("healthy");
Integer code = response.getInteger("code");
if ("ok".equalsIgnoreCase(status) || "healthy".equalsIgnoreCase(status)
|| Boolean.TRUE.equals(healthy) || (code != null && code == 200)) {
return true;
}
}
return false;
} catch (Exception e) {
String lowerResult = healthResult.toLowerCase();
return lowerResult.contains("ok") || lowerResult.contains("healthy") || lowerResult.contains("success");
}
} }
@Override @Override
@@ -101,46 +184,26 @@ public class LogisticsServiceImpl implements ILogisticsService {
@Override @Override
public ILogisticsService.HealthCheckResult checkHealth() { public ILogisticsService.HealthCheckResult checkHealth() {
List<String> errors = new ArrayList<>();
for (String base : logisticsServiceBases) {
String url = base + logisticsHealthPath;
try { try {
logger.debug("开始检查物流服务健康状态 - URL: {}", healthCheckUrl); logger.debug("开始检查物流服务健康状态 - URL: {}", url);
String healthResult = HttpUtils.sendGet(healthCheckUrl); String healthResult = HttpUtils.sendGet(url);
if (isHealthResponseBodyHealthy(healthResult)) {
if (healthResult == null || healthResult.trim().isEmpty()) { logger.debug("物流服务健康检查通过 - {}", url);
logger.warn("物流服务健康检查返回空结果"); return new ILogisticsService.HealthCheckResult(true, "正常", "服务运行正常: " + url,
return new ILogisticsService.HealthCheckResult(false, "异常", "健康检查返回空结果", healthCheckUrl); logisticsServiceBasesSummary);
}
// 尝试解析JSON响应
try {
JSONObject response = JSON.parseObject(healthResult);
if (response != null) {
// 检查常见的健康状态字段
String status = response.getString("status");
Boolean healthy = response.getBoolean("healthy");
Integer code = response.getInteger("code");
if ("ok".equalsIgnoreCase(status) || "healthy".equalsIgnoreCase(status) ||
Boolean.TRUE.equals(healthy) || (code != null && code == 200)) {
logger.debug("物流服务健康检查通过");
return new ILogisticsService.HealthCheckResult(true, "正常", "服务运行正常", healthCheckUrl);
}
} }
logger.warn("物流服务健康检查失败 - URL: {} 响应: {}", url, healthResult);
errors.add(url + " -> " + (healthResult == null || healthResult.isEmpty() ? "空响应" : "状态异常"));
} catch (Exception e) { } catch (Exception e) {
// 如果不是JSON格式检查是否包含成功标识 logger.error("物流服务健康检查异常 - URL: {}, 错误: {}", url, e.getMessage(), e);
String lowerResult = healthResult.toLowerCase(); errors.add(url + " -> " + e.getMessage());
if (lowerResult.contains("ok") || lowerResult.contains("healthy") || lowerResult.contains("success")) {
logger.debug("物流服务健康检查通过非JSON格式");
return new ILogisticsService.HealthCheckResult(true, "正常", "服务运行正常", healthCheckUrl);
} }
} }
String msg = errors.isEmpty() ? "未配置物流解析实例" : String.join("; ", errors);
logger.warn("物流服务健康检查失败 - 响应: {}", healthResult); return new ILogisticsService.HealthCheckResult(false, "异常", msg, logisticsServiceBasesSummary);
return new ILogisticsService.HealthCheckResult(false, "异常", "健康检查返回异常状态: " + healthResult, healthCheckUrl);
} catch (Exception e) {
logger.error("物流服务健康检查异常 - URL: {}, 错误: {}", healthCheckUrl, e.getMessage(), e);
return new ILogisticsService.HealthCheckResult(false, "异常", "健康检查异常: " + e.getMessage(), healthCheckUrl);
}
} }
/** /**
@@ -179,7 +242,7 @@ public class LogisticsServiceImpl implements ILogisticsService {
// 构建推送消息 // 构建推送消息
StringBuilder pushContent = new StringBuilder(); StringBuilder pushContent = new StringBuilder();
pushContent.append("【物流服务异常提醒】\n"); pushContent.append("【物流服务异常提醒】\n");
pushContent.append("服务地址").append(healthCheckUrl).append("\n"); pushContent.append("服务实例").append(logisticsServiceBasesSummary).append("\n");
pushContent.append("失败原因:").append(reason).append("\n"); pushContent.append("失败原因:").append(reason).append("\n");
pushContent.append("时间:").append(new Date()).append("\n"); pushContent.append("时间:").append(new Date()).append("\n");
pushContent.append("请及时检查服务状态!"); pushContent.append("请及时检查服务状态!");
@@ -215,6 +278,52 @@ public class LogisticsServiceImpl implements ILogisticsService {
} }
} }
/**
* 京东单已写入 Redis 运单后,联动闲鱼单同步并发货(失败不影响物流主流程)。
* 若存在关联闲鱼单,会先写入事件来源 JD_LOGISTICS_PUSH便于与 REDIS_WAYBILL / AUTO_SHIP 对照。
*/
private void safeNotifyGoofishShip(Long jdOrderId, String waybillNo, String traceSummary) {
try {
mirrorFxianyuGoofishSecondaryWaybillRedis(jdOrderId, waybillNo);
erpGoofishOrderService.traceJdLogisticsPushForGoofish(jdOrderId, waybillNo, traceSummary);
erpGoofishOrderService.notifyJdWaybillReady(jdOrderId);
} catch (Exception e) {
logger.warn("闲鱼发货联动异常 jdOrderId={} err={}", jdOrderId, e.toString());
}
}
/**
* F-闲鱼等:将运单镜像到 logistics:waybill:goofish:${第三方单号},即使 erp_goofish_order.jd_order_id 尚未关联也能被同步。
*/
private void mirrorFxianyuGoofishSecondaryWaybillRedis(Long jdOrderDbId, String waybillNo) {
if (jdOrderDbId == null || !StringUtils.hasText(waybillNo)) {
return;
}
JDOrder jd = jdOrderService.selectJDOrderById(jdOrderDbId);
if (jd == null) {
return;
}
if (!isFxianyuJdDistributionMark(jd.getDistributionMark())) {
return;
}
String tp = jd.getThirdPartyOrderNo();
if (!StringUtils.hasText(tp)) {
return;
}
stringRedisTemplate.opsForValue().set(
GoofishOrderPipeline.REDIS_WAYBILL_GOOFISH_ORDER_PREFIX + tp.trim(),
waybillNo.trim(),
30, TimeUnit.DAYS);
}
private static boolean isFxianyuJdDistributionMark(String distributionMark) {
if (!StringUtils.hasText(distributionMark)) {
return false;
}
String m = distributionMark.trim();
return m.startsWith("F") || m.contains("\u95f2\u9c7c");
}
@Override @Override
public boolean fetchLogisticsAndPush(JDOrder order) { public boolean fetchLogisticsAndPush(JDOrder order) {
if (order == null || order.getId() == null) { if (order == null || order.getId() == null) {
@@ -260,8 +369,7 @@ public class LogisticsServiceImpl implements ILogisticsService {
return false; return false;
} }
// 构建外部接口URL String externalUrl = buildFetchLogisticsRequestUrl(logisticsLink);
String externalUrl = externalApiUrlTemplate + URLEncoder.encode(logisticsLink, "UTF-8");
logger.info("调用外部接口获取物流信息 - 订单ID: {}, URL: {}", orderId, externalUrl); logger.info("调用外部接口获取物流信息 - 订单ID: {}, URL: {}", orderId, externalUrl);
// 在服务端执行HTTP请求 // 在服务端执行HTTP请求
@@ -375,6 +483,8 @@ public class LogisticsServiceImpl implements ILogisticsService {
logger.info("订单运单号已存在且一致,说明之前已推送过,跳过重复推送 - 订单ID: {}, waybill_no: {}", orderId, waybillNo); logger.info("订单运单号已存在且一致,说明之前已推送过,跳过重复推送 - 订单ID: {}, waybill_no: {}", orderId, waybillNo);
// 更新过期时间,确保记录不会过期 // 更新过期时间,确保记录不会过期
stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS); stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS);
safeNotifyGoofishShip(orderId, waybillNo,
"Redis 运单与本次一致,跳过重复企微推送;已刷新 TTL随后触发闲鱼同步");
return true; return true;
} }
@@ -392,6 +502,8 @@ public class LogisticsServiceImpl implements ILogisticsService {
logger.info("订单创建时间较早({}且Redis中无记录但已获取到运单号视为之前已推送过直接标记为已处理跳过推送 - 订单ID: {}, waybill_no: {}", logger.info("订单创建时间较早({}且Redis中无记录但已获取到运单号视为之前已推送过直接标记为已处理跳过推送 - 订单ID: {}, waybill_no: {}",
order.getCreateTime(), orderId, waybillNo); order.getCreateTime(), orderId, waybillNo);
stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS); stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS);
safeNotifyGoofishShip(orderId, waybillNo,
"老单兜底:直写 Redis跳过企微随后触发闲鱼同步");
return true; return true;
} }
} }
@@ -418,6 +530,14 @@ public class LogisticsServiceImpl implements ILogisticsService {
// 更新过期时间,确保记录不会过期 // 更新过期时间,确保记录不会过期
stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS); stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS);
} }
String mark = order.getDistributionMark();
boolean skipGoofishJdWexin = erpGoofishOrderService.hasLinkedGoofishOrder(orderId)
|| (mark != null && mark.contains("\u95f2\u9c7c"));
String traceTail = (logisticsLinkUpdated ? "物流链接已更新;" : "物流链接未变;") + "Redis 已写入;随后触发闲鱼同步";
String traceSummary = (skipGoofishJdWexin
? "闲鱼单本环节未发京东物流企微,真发货见 SHIP 日志;"
: "企微货主推送成功;") + traceTail;
safeNotifyGoofishShip(orderId, waybillNo, traceSummary);
// 记录最终处理结果 // 记录最终处理结果
if (logisticsLinkUpdated) { if (logisticsLinkUpdated) {
@@ -472,6 +592,11 @@ public class LogisticsServiceImpl implements ILogisticsService {
logger.warn("adhoc 入队跳过jobKey 或 trackingUrl 为空"); logger.warn("adhoc 入队跳过jobKey 或 trackingUrl 为空");
return; return;
} }
WeComShareLinkLogisticsJob row = weComShareLinkLogisticsJobMapper.selectByJobKey(job.getJobKey().trim());
if (row != null && "CANCELLED".equalsIgnoreCase(row.getStatus())) {
logger.info("adhoc 入队跳过:任务已取消 jobKey={}", job.getJobKey());
return;
}
int attempts = job.getScanAttempts() != null ? job.getScanAttempts() : 0; int attempts = job.getScanAttempts() != null ? job.getScanAttempts() : 0;
rightPushAdhocQueueJson(job.getJobKey().trim(), attempts, job.getTrackingUrl().trim(), rightPushAdhocQueueJson(job.getJobKey().trim(), attempts, job.getTrackingUrl().trim(),
job.getUserRemark(), job.getTouserPush(), job.getFromUserName()); job.getUserRemark(), job.getTouserPush(), job.getFromUserName());
@@ -561,6 +686,13 @@ public class LogisticsServiceImpl implements ILogisticsService {
String touser = o.getString("touser"); String touser = o.getString("touser");
String jobKey = o.getString("jobKey"); String jobKey = o.getString("jobKey");
int attempts = o.getIntValue("attempts"); int attempts = o.getIntValue("attempts");
if (StringUtils.hasText(jobKey)) {
WeComShareLinkLogisticsJob row = weComShareLinkLogisticsJobMapper.selectByJobKey(jobKey.trim());
if (row == null || "CANCELLED".equalsIgnoreCase(row.getStatus())) {
logger.info("adhoc 队列项跳过(任务已删除或已取消扫描) jobKey={} rowNull={}", jobKey, row == null);
continue;
}
}
AdhocTryResult tr = tryAdhocShareLinkOnce(url, remark, touser, null); AdhocTryResult tr = tryAdhocShareLinkOnce(url, remark, touser, null);
if (tr.needsRequeue) { if (tr.needsRequeue) {
int nextAttempts = attempts + 1; int nextAttempts = attempts + 1;
@@ -659,7 +791,7 @@ public class LogisticsServiceImpl implements ILogisticsService {
if (debug != null) { if (debug != null) {
debug.put("healthOk", true); debug.put("healthOk", true);
} }
String externalUrl = externalApiUrlTemplate + URLEncoder.encode(url, "UTF-8"); String externalUrl = buildFetchLogisticsRequestUrl(url);
if (debug != null) { if (debug != null) {
debug.put("requestUrl", externalUrl); debug.put("requestUrl", externalUrl);
} }
@@ -781,41 +913,46 @@ public class LogisticsServiceImpl implements ILogisticsService {
*/ */
private boolean sendEnterprisePushNotification(JDOrder order, String waybillNo, boolean logisticsLinkUpdated, String oldLogisticsLink, String newLogisticsLink) { private boolean sendEnterprisePushNotification(JDOrder order, String waybillNo, boolean logisticsLinkUpdated, String oldLogisticsLink, String newLogisticsLink) {
try { try {
// 构建推送消息内容 String distributionMark = order.getDistributionMark() != null ? order.getDistributionMark() : "\u672a\u77e5";
StringBuilder pushContent = new StringBuilder();
// 第一行分销标识F或PDD
String distributionMark = order.getDistributionMark() != null ? order.getDistributionMark() : "未知";
pushContent.append(distributionMark).append("\n");
String thirdPartyOrderNo = order.getThirdPartyOrderNo(); String thirdPartyOrderNo = order.getThirdPartyOrderNo();
String modelStr = order.getModelNumber() != null ? order.getModelNumber() : "\u65e0";
String addressStr = order.getAddress() != null ? order.getAddress() : "\u65e0";
boolean goofishLinked = erpGoofishOrderService.hasLinkedGoofishOrder(order.getId())
|| (distributionMark != null && distributionMark.contains("\u95f2\u9c7c"));
if (goofishLinked) {
// 闲鱼单:不在京东扫到运单环节发企微;仅写 Redis 并 notifyJdWaybillReady / tryAutoShip。
// 真发货成功后由 GoofishOrderChangeLogger SHIP 事件发 wx。
logger.info("闲鱼关联/分销:跳过本环节企微直推 - 订单ID: {}, 订单号: {}, waybill_no: {}",
order.getId(), order.getOrderId(), waybillNo);
return true;
}
// 非闲鱼PDD 企微,完整 JD 物流版式
StringBuilder std = new StringBuilder();
std.append("JD物流信息推送").append("\n");
std.append(distributionMark).append("\n");
if (thirdPartyOrderNo != null && !thirdPartyOrderNo.trim().isEmpty()) { if (thirdPartyOrderNo != null && !thirdPartyOrderNo.trim().isEmpty()) {
pushContent.append("第三方单号:").append(thirdPartyOrderNo).append("\n"); std.append("第三方单号:").append(thirdPartyOrderNo.trim()).append("\n");
} }
std.append("型号:").append(modelStr).append("\n");
// 型号 std.append("收货地址:").append(addressStr).append("\n");
pushContent.append("型号:").append(order.getModelNumber() != null ? order.getModelNumber() : "").append("\n");
// 收货地址
pushContent.append("收货地址:").append(order.getAddress() != null ? order.getAddress() : "").append("\n");
// 如果物流链接已更新,在推送消息中说明
if (logisticsLinkUpdated && newLogisticsLink != null && !newLogisticsLink.trim().isEmpty()) { if (logisticsLinkUpdated && newLogisticsLink != null && !newLogisticsLink.trim().isEmpty()) {
pushContent.append("【物流链接已更新】").append("\n"); std.append("【物流链接已更新】").append("\n");
pushContent.append("新物流链接:").append(newLogisticsLink.trim()).append("\n"); std.append("新物流链接:").append(newLogisticsLink.trim()).append("\n");
if (oldLogisticsLink != null && !oldLogisticsLink.trim().isEmpty()) { if (oldLogisticsLink != null && !oldLogisticsLink.trim().isEmpty()) {
pushContent.append("旧物流链接:").append(oldLogisticsLink.trim()).append("\n"); std.append("旧物流链接:").append(oldLogisticsLink.trim()).append("\n");
} }
pushContent.append("\n"); std.append("\n");
} }
std.append("运单号:").append("\n").append("\n").append("\n").append("\n").append(waybillNo).append("\n");
String fullText = std.toString();
// 运单号 // 调用企业微信推送接口PDD 自建应用)
pushContent.append("运单号:").append("\n").append("\n").append("\n").append("\n").append(waybillNo).append("\n");
// 调用企业微信推送接口
JSONObject pushParam = new JSONObject(); JSONObject pushParam = new JSONObject();
pushParam.put("title", "JD物流信息推送"); pushParam.put("title", "");
pushParam.put("text", pushContent.toString()); pushParam.put("text", fullText);
// 根据分销标识获取接收人列表 // 根据分销标识获取接收人列表
String touser = getTouserByDistributionMark(distributionMark); String touser = getTouserByDistributionMark(distributionMark);
@@ -884,22 +1021,32 @@ public class LogisticsServiceImpl implements ILogisticsService {
} }
try { try {
// 构建配置键名 String trimmed = distributionMark.trim();
String configKey = CONFIG_KEY_PREFIX + distributionMark.trim(); // 构建配置键名(可与订单中的标识完全一致,如 F-王杰)
String configKey = CONFIG_KEY_PREFIX + trimmed;
// 从系统配置中获取接收人列表
String configValue = sysConfigService.selectConfigByKey(configKey); String configValue = sysConfigService.selectConfigByKey(configKey);
if (StringUtils.hasText(configValue)) { if (StringUtils.hasText(configValue)) {
// 清理配置值(去除空格)
String touser = configValue.trim().replaceAll(",\\s+", ","); String touser = configValue.trim().replaceAll(",\\s+", ",");
logger.info("从配置获取接收人列表 - 分销标识: {}, 配置键: {}, 接收人: {}", logger.info("从配置获取接收人列表 - 分销标识: {}, 配置键: {}, 接收人: {}",
distributionMark, configKey, touser); distributionMark, configKey, touser);
return touser; return touser;
} else { }
// F、F-中文 等未单独配置时,共用 logistics.push.touser.F
if (trimmed.startsWith("F-") || "F".equals(trimmed)) {
if (!trimmed.equals("F")) {
String fallbackKey = CONFIG_KEY_PREFIX + "F";
String fallbackVal = sysConfigService.selectConfigByKey(fallbackKey);
if (StringUtils.hasText(fallbackVal)) {
String touser = fallbackVal.trim().replaceAll(",\\s+", ",");
logger.info("从配置获取接收人列表F 系回退) - 分销标识: {}, 配置键: {}, 接收人: {}",
distributionMark, fallbackKey, touser);
return touser;
}
}
}
logger.debug("未找到接收人配置 - 分销标识: {}, 配置键: {}", distributionMark, configKey); logger.debug("未找到接收人配置 - 分销标识: {}, 配置键: {}", distributionMark, configKey);
return null; return null;
}
} catch (Exception e) { } catch (Exception e) {
logger.error("获取接收人配置失败 - 分销标识: {}, 错误: {}", distributionMark, e.getMessage(), e); logger.error("获取接收人配置失败 - 分销标识: {}, 错误: {}", distributionMark, e.getMessage(), e);
return null; return null;

View File

@@ -10,18 +10,25 @@ import org.springframework.util.StringUtils;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL; import java.net.URL;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* 企微「开」+ 手机号POST 局域网 /v1/forward将 JSON 中的 {@code reply_text} 作为回显。 * 企微「开」/「慢开」+ 手机号POST 局域网 /v1/forwardbody 含 {@code text}(手机号)与 {@code bot}
* 将 JSON 中的 {@code reply_text} 作为回显。
* <p> * <p>
* 可配置 {@code wait_reply} / {@code reply_take_nth}:服务端先发 text再等 Bot 回 N 次, * {@code wait_reply} 时:{@code AJL05_bot} 固定取第 2 条;{@code QingBaoJuXWsgkbot} 由 tg_bridge
* N内容填入 {@code reply_text}(前几条如「查询中…」由对端丢弃) * 在同一会话内多次收取(仅一次发送 query2是否已为结果决定在 2/3 条间取值,避免重复计费
* 「慢开」返回仍会去掉尾部固定推广行。
* </p> * </p>
*/ */
@Service @Service
@@ -31,6 +38,34 @@ public class OpenPhoneForwardService {
private static final Pattern MOBILE_11 = Pattern.compile("(1\\d{10})"); private static final Pattern MOBILE_11 = Pattern.compile("(1\\d{10})");
/** 「开」→ 对应 Bot 用户名(与对端 {@code bot} 字段一致,不含 @ */
private static final String BOT_OPEN = "AJL05_bot";
/** 「慢开」→ 对应 Bot 用户名 */
private static final String BOT_SLOW_OPEN = "QingBaoJuXWsgkbot";
private static final int REPLY_TAKE_NTH_OPEN_BOT = 2;
/**
* 情报局推广条Telegram 常为 Markdown
* {@code **👉‍**[**文案**](url)**👈**};另保留旧版纯文本。
*/
private static final Pattern[] QINGBAO_REPLY_JUNK_PATTERNS = {
Pattern.compile(
"\\*\\*👉\u200D?\\*\\*\\[\\*\\*公安路线查询价格表\\*\\*\\]"
+ "\\(https?://t\\.me/\\+C20ADPmEKJU0ZGFl\\)\\*\\*👈\\*\\*"),
Pattern.compile(
"\\*\\*👉\u200D?\\*\\*\\[\\*\\*如机器人提示被注销点我防丢\\*\\*\\]"
+ "\\(https?://telegra\\.ph/qingbaoju-10-01\\)\\*\\*👈\\*\\*"),
};
private static final String[] QINGBAO_REPLY_JUNK_LITERAL = {
"\uD83D\uDC49\u200D公安路线查询价格表 (https://t.me/+C20ADPmEKJU0ZGFl)\uD83D\uDC48",
"\uD83D\uDC49\u200D如机器人提示被注销点我防丢 (https://telegra.ph/qingbaoju-10-01)\uD83D\uDC48",
"\uD83D\uDC49公安路线查询价格表 (https://t.me/+C20ADPmEKJU0ZGFl)\uD83D\uDC48",
"\uD83D\uDC49如机器人提示被注销点我防丢 (https://telegra.ph/qingbaoju-10-01)\uD83D\uDC48",
};
@Value("${jarvis.phone-forward.enabled:false}") @Value("${jarvis.phone-forward.enabled:false}")
private boolean enabled; private boolean enabled;
@@ -50,10 +85,24 @@ public class OpenPhoneForwardService {
@Value("${jarvis.phone-forward.wait-reply:true}") @Value("${jarvis.phone-forward.wait-reply:true}")
private boolean waitReply; private boolean waitReply;
/** 与 wait_reply 配合:取第几条 Bot 回复作为 reply_text须 ≥ 1 */ /** 与 tg_bridge 串行:多线程同时「开」时排队,避免 Python 端会话串话0 表示无限等待 */
@Value("${jarvis.phone-forward.reply-take-nth:2}") @Value("${jarvis.phone-forward.lock-acquire-timeout-ms:180000}")
private int replyTakeNth; private long lockAcquireTimeoutMs;
/** 连续失败达到阈值后,在 openDurationMs 内直接拒绝调用(不发起 HTTP */
@Value("${jarvis.phone-forward.circuit-failure-threshold:5}")
private int circuitFailureThreshold;
@Value("${jarvis.phone-forward.circuit-open-ms:120000}")
private long circuitOpenMs;
/** 仅允许单飞:所有 phone-forward 请求串行 */
private final ReentrantLock tgBridgeCallLock = new ReentrantLock(true);
private final AtomicInteger circuitFailureCount = new AtomicInteger(0);
/** 熔断恢复时间epoch ms0 表示未熔断 */
private final AtomicLong circuitOpenUntilMs = new AtomicLong(0);
/** /**
* @return 非 null 表示本条消息已由本服务处理含错误提示null 表示不匹配规则 * @return 非 null 表示本条消息已由本服务处理含错误提示null 表示不匹配规则
*/ */
@@ -62,14 +111,50 @@ public class OpenPhoneForwardService {
return null; return null;
} }
String text = rawContent.trim().replaceFirst("^\uFEFF", ""); String text = rawContent.trim().replaceFirst("^\uFEFF", "");
if (!text.startsWith("")) { String bot;
if (text.startsWith("慢开")) {
bot = BOT_SLOW_OPEN;
} else if (text.startsWith("")) {
bot = BOT_OPEN;
} else {
return null; return null;
} }
String phone = extractFirstMobile11(text); String phone = extractFirstMobile11(text);
if (phone == null) { if (phone == null) {
return null; return null;
} }
return doForward(phone); return doForward(phone, bot);
}
private boolean isCircuitOpen() {
long until = circuitOpenUntilMs.get();
return until > 0L && System.currentTimeMillis() < until;
}
private void recordSuccess() {
circuitFailureCount.set(0);
circuitOpenUntilMs.set(0L);
}
/** HTTP/网络类失败、5xx、超时记入达到阈值则熔断一段时间 */
private void recordFailure() {
long now = System.currentTimeMillis();
long until = circuitOpenUntilMs.get();
if (until > now) {
return;
}
int n = circuitFailureCount.incrementAndGet();
int thr = Math.max(1, circuitFailureThreshold);
if (n >= thr) {
long openUntil = now + Math.max(1000L, circuitOpenMs);
circuitOpenUntilMs.set(openUntil);
circuitFailureCount.set(0);
log.warn("phone-forward 熔断开启至 epochMs={}(连续失败 ≥ {}", openUntil, thr);
}
}
private static boolean shouldTripCircuit(int httpCode) {
return httpCode == 504 || httpCode >= 500;
} }
private static String extractFirstMobile11(String text) { private static String extractFirstMobile11(String text) {
@@ -80,7 +165,39 @@ public class OpenPhoneForwardService {
return null; return null;
} }
private String doForward(String phone) { private String doForward(String phone, String bot) {
if (isCircuitOpen()) {
log.warn("phone-forward 熔断拒绝 phone={} bot={}", phone, bot);
return "「转发服务」暂时不可用(连续失败保护中),请一分钟后再试。";
}
boolean locked = false;
try {
if (lockAcquireTimeoutMs <= 0) {
tgBridgeCallLock.lock();
locked = true;
} else {
locked = tgBridgeCallLock.tryLock(lockAcquireTimeoutMs, TimeUnit.MILLISECONDS);
if (!locked) {
log.warn(
"phone-forward 排队超时上一条未完成phone={} bot={} ms={}",
phone, bot, lockAcquireTimeoutMs);
return "「转发服务」正忙(上一条查询尚未结束),请稍后再试。";
}
}
return doForwardUnsynchronized(phone, bot);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("phone-forward 获取锁被打断 phone={} bot={}", phone, bot);
return "「转发服务」被中断,请稍后再试。";
} finally {
if (locked) {
tgBridgeCallLock.unlock();
}
}
}
private String doForwardUnsynchronized(String phone, String bot) {
try { try {
String base = baseUrl.trim(); String base = baseUrl.trim();
if (base.endsWith("/")) { if (base.endsWith("/")) {
@@ -97,13 +214,14 @@ public class OpenPhoneForwardService {
JSONObject body = new JSONObject(); JSONObject body = new JSONObject();
body.put("text", phone); body.put("text", phone);
body.put("bot", bot);
if (waitReply) { if (waitReply) {
int nth = replyTakeNth >= 1 ? replyTakeNth : 1;
if (replyTakeNth < 1) {
log.warn("phone-forward reply-take-nth={} 无效,已按 1 处理", replyTakeNth);
}
body.put("wait_reply", true); body.put("wait_reply", true);
body.put("reply_take_nth", nth); if (BOT_SLOW_OPEN.equals(bot)) {
body.put("reply_adaptive_skip_middle_ad", true);
} else {
body.put("reply_take_nth", REPLY_TAKE_NTH_OPEN_BOT);
}
} }
byte[] bytes = body.toJSONString().getBytes(StandardCharsets.UTF_8); byte[] bytes = body.toJSONString().getBytes(StandardCharsets.UTF_8);
@@ -124,28 +242,54 @@ public class OpenPhoneForwardService {
String resp = readAll(is); String resp = readAll(is);
if (code < 200 || code >= 300) { if (code < 200 || code >= 300) {
log.warn("phone-forward HTTP {} url={} body={}", code, urlStr, resp); log.warn("phone-forward HTTP {} url={} body={}", code, urlStr, resp);
if (shouldTripCircuit(code)) {
recordFailure();
}
return "「转发服务」请求失败HTTP " + code + "),请稍后再试。"; return "「转发服务」请求失败HTTP " + code + "),请稍后再试。";
} }
JSONObject jo = JSONObject.parseObject(resp); JSONObject jo = JSONObject.parseObject(resp);
if (jo == null) { if (jo == null) {
recordFailure();
return "「转发服务」返回异常,请稍后再试。"; return "「转发服务」返回异常,请稍后再试。";
} }
String reply = jo.getString("reply_text"); String reply = jo.getString("reply_text");
if (!StringUtils.hasText(reply)) { if (!StringUtils.hasText(reply)) {
recordFailure();
return "「转发服务」未返回 reply_text。"; return "「转发服务」未返回 reply_text。";
} }
if (BOT_SLOW_OPEN.equals(bot)) {
reply = filterQingBaoAdLines(reply);
}
recordSuccess();
return reply; return reply;
} finally { } finally {
if (conn != null) { if (conn != null) {
conn.disconnect(); conn.disconnect();
} }
} }
} catch (SocketTimeoutException e) {
log.warn("phone-forward 超时 phone={} bot={} err={}", phone, bot, e.toString());
recordFailure();
return "「转发服务」超时,请稍后再试。";
} catch (Exception e) { } catch (Exception e) {
log.warn("phone-forward 异常 phone={} err={}", phone, e.toString()); log.warn("phone-forward 异常 phone={} bot={} err={}", phone, bot, e.toString());
recordFailure();
return "「转发服务」连接失败,请确认 Jarvis 与局域网服务可达。"; return "「转发服务」连接失败,请确认 Jarvis 与局域网服务可达。";
} }
} }
/** 将固定广告段Markdown 与纯文本)替换为空格后 trim。 */
private static String filterQingBaoAdLines(String reply) {
String s = reply;
for (Pattern p : QINGBAO_REPLY_JUNK_PATTERNS) {
s = p.matcher(s).replaceAll(" ");
}
for (String junk : QINGBAO_REPLY_JUNK_LITERAL) {
s = s.replace(junk, " ");
}
return s.trim();
}
private static String readAll(InputStream is) throws java.io.IOException { private static String readAll(InputStream is) throws java.io.IOException {
if (is == null) { if (is == null) {
return ""; return "";

View File

@@ -50,7 +50,8 @@ public class SocialMediaServiceImpl implements ISocialMediaService
"content:both", "content:both",
"xianyu:wenan_base", "xianyu:wenan_base",
"xianyu:jiaonixiadan_extra", "xianyu:jiaonixiadan_extra",
"xianyu:title_clean_regex" "xianyu:title_clean_regex",
"xianyu:seed_note_prompt"
}; };
// 模板说明 // 模板说明
@@ -62,6 +63,7 @@ public class SocialMediaServiceImpl implements ISocialMediaService
put("xianyu:wenan_base", "闲鱼文案·正文基础说明\n用于「一键代下」与「教你下单」两版文案中紧接在标题/型号行之后(纯文本,无占位符)"); put("xianyu:wenan_base", "闲鱼文案·正文基础说明\n用于「一键代下」与「教你下单」两版文案中紧接在标题/型号行之后(纯文本,无占位符)");
put("xianyu:jiaonixiadan_extra", "闲鱼文案·教你下单版尾部附加说明\n接在「更新日期yyyy-MM-dd」之后纯文本"); put("xianyu:jiaonixiadan_extra", "闲鱼文案·教你下单版尾部附加说明\n接在「更新日期yyyy-MM-dd」之后纯文本");
put("xianyu:title_clean_regex", "闲鱼文案·标题/型号清洗正则\n从标题与型号备注中移除营销敏感片段须为 Java 正则,匹配到的内容会被删除"); put("xianyu:title_clean_regex", "闲鱼文案·标题/型号清洗正则\n从标题与型号备注中移除营销敏感片段须为 Java 正则,匹配到的内容会被删除");
put("xianyu:seed_note_prompt", "闲鱼文案·种草文案提示词模板\n占位符{{goods_title}} {{goods_model}} {{goods_type}} {{goods_brand}} {{channel_source}} {{official_price}} {{sell_price}} {{warranty}} {{delivery_install}}");
}}; }};
/** 多套大模型 JSON 存储,与 Jarvis_java SocialMediaLlmClient 一致 */ /** 多套大模型 JSON 存储,与 Jarvis_java SocialMediaLlmClient 一致 */
@@ -88,6 +90,50 @@ public class SocialMediaServiceImpl implements ISocialMediaService
+ "优先回复你合适的下单渠道和详细步骤,让你安全省钱地完成下单。"; + "优先回复你合适的下单渠道和详细步骤,让你安全省钱地完成下单。";
/** 标题/型号清洗:去掉营销敏感词 */ /** 标题/型号清洗:去掉营销敏感词 */
private static final String DEFAULT_XIANYU_TITLE_CLEAN_REGEX = "以旧|政府|换新|领取|国家|补贴|15%|20%|国补|立减|【|】"; private static final String DEFAULT_XIANYU_TITLE_CLEAN_REGEX = "以旧|政府|换新|领取|国家|补贴|15%|20%|国补|立减|【|】";
/** 闲鱼种草文案提示词模板Redis 未配置时使用) */
private static final String DEFAULT_XIANYU_SEED_NOTE_PROMPT =
"# 角色设定\n"
+ "你是拥有10年家电行业经验的资深渠道商同时是闲鱼平台家电类目TOP级种草文案达人精通闲鱼家电用户的消费心理、搜索流量规则、高转化文案写作逻辑擅长用专业且亲切的口吻为全新未拆封的正品家电写出强信任、高种草、高成交的闲鱼转卖文案拒绝冰冷的官方参数堆砌、浮夸的营销硬广、二手闲置话术。\n\n"
+ "# 核心任务\n"
+ "严格基于我提供的家电商品信息,生成一段完全适配闲鱼平台、符合全新正品家电属性、有强吸引力、高转化的种草文案,全程遵循以下所有规则。\n\n"
+ "# 家电商品输入信息(接口传入)\n"
+ "【必传核心信息】\n"
+ "1. 商品完整标题:{{goods_title}}\n"
+ "2. 商品精准完整型号:{{goods_model}}\n"
+ "3. 家电品类类型:{{goods_type}}\n\n"
+ "【可选补充信息(无则按默认兜底规则生成)】\n"
+ "4. 商品品牌:{{goods_brand}}\n"
+ "5. 渠道来源:{{channel_source}}\n"
+ "6. 官方指导价:{{official_price}}\n"
+ "7. 闲鱼售卖价格:{{sell_price}}\n"
+ "8. 官方质保政策:{{warranty}}\n"
+ "9. 配送安装政策:{{delivery_install}}\n\n"
+ "# 字段优先级&兜底规则(必须严格遵守)\n"
+ "1. 型号优先级:必须以「{{goods_model}}」为唯一精准型号,商品标题里的型号简写仅做补充,禁止写错、简写型号\n"
+ "2. 品类优先级:必须以「{{goods_type}}」为唯一品类标准,所有场景、卖点、话术必须围绕该家电品类展开\n"
+ "3. 品牌兜底:若{{goods_brand}}为空,自动从{{goods_title}}中提取品牌名,提取不到则不强制体现\n"
+ "4. 渠道兜底:若{{channel_source}}为空,默认使用「品牌授权经销商直供」,禁止使用二手、个人闲置相关渠道描述\n"
+ "5. 价格兜底:若{{official_price}}为空,默认使用「比官方旗舰店优惠力度大」相关话术;若{{sell_price}}为空,不写固定售价,引导用户私信询价\n"
+ "6. 售后兜底:若{{warranty}}为空,默认使用「品牌官方全国联保,和官方旗舰店享受同等售后权益」\n"
+ "7. 配送兜底:若{{delivery_install}}为空,默认使用「全国包邮送货入户,乡镇可达」\n\n"
+ "# 核心字段强制融合规则必须100%严格执行,解决生硬堆砌问题)\n"
+ "1. 标题植入规则:生成的闲鱼标题,必须同时包含「{{goods_type}}+{{goods_model}}+品牌+全新正品」4个核心元素同时适配闲鱼家电用户搜索习惯字数控制在25-35字\n"
+ "2. 正文植入规则:\n"
+ " - 开篇前30字必须同时出现「{{goods_type}}+{{goods_model}}」,强化型号和品类,同时给出正品信任背书\n"
+ " - 正文卖点模块,必须围绕「{{goods_type}}」的品类核心需求展开,同时绑定{{goods_model}}型号,禁止脱离品类写通用卖点\n"
+ " - 正文至少3次自然提及{{goods_model}}完整型号适配闲鱼搜索权重规则禁止只出现1次\n"
+ " - 正文所有场景化描述,必须贴合{{goods_type}}的使用场景,比如空调对应卧室/客厅制冷、冰箱对应食材囤货/嵌入装修、洗衣机对应家庭大容量/除菌等\n"
+ "3. 结尾植入规则:结尾行动引导和话题标签,必须包含「{{goods_type}}」相关流量词标签数量4-6个必须有1个标签带{{goods_model}}型号\n"
+ "4. 卖点提取规则:核心卖点自动从{{goods_title}}中提取,优先提取能效、容量、变频、智能、超薄、除菌等家电核心决策卖点,转化为场景化话术,禁止参数堆砌\n\n"
+ "# 创作必须严格遵守的通用规则\n"
+ "1. 全程使用第一人称「我/我们」写作,贴合家电渠道商的专业且亲切的身份,像给朋友推荐靠谱好货,绝对不能出现官方旗舰店硬广话术、二手个人闲置转手话术,全程围绕「全新未拆封正品家电」核心属性创作\n"
+ "2. 文案100%基于接口传入的信息创作,不得凭空捏造与输入信息不符的功能、参数、质保、服务政策,核心卖点必须转化为用户可感知的场景化好处\n"
+ "3. 必须完整包含以下核心模块,顺序不可调换,缺一不可:吸睛搜索标题、开篇信任背书、正品保障、卖点拆解、价格优势、配送安装、售后承诺、结尾行动和标签\n"
+ "4. 文案排版短句为主段落清晰每段不超过3行手机阅读友好\n"
+ "5. 字数要求正文控制在400-600字\n"
+ "6. 合规要求严格符合闲鱼平台规则和3C家电宣传规范不使用极限词不夸大宣传\n\n"
+ "# 输出格式\n"
+ "直接输出最终完整文案,不要额外解释。";
/** /**
* Redis 无记录时与 Jarvis_java SocialMediaService 使用的默认模板一致,供接口回显到前端参考。 * Redis 无记录时与 Jarvis_java SocialMediaService 使用的默认模板一致,供接口回显到前端参考。
@@ -139,6 +185,7 @@ public class SocialMediaServiceImpl implements ISocialMediaService
put("xianyu:wenan_base", DEFAULT_XIANYU_WENAN_BASE); put("xianyu:wenan_base", DEFAULT_XIANYU_WENAN_BASE);
put("xianyu:jiaonixiadan_extra", DEFAULT_XIANYU_JIAONIXIADAN_EXTRA); put("xianyu:jiaonixiadan_extra", DEFAULT_XIANYU_JIAONIXIADAN_EXTRA);
put("xianyu:title_clean_regex", DEFAULT_XIANYU_TITLE_CLEAN_REGEX); put("xianyu:title_clean_regex", DEFAULT_XIANYU_TITLE_CLEAN_REGEX);
put("xianyu:seed_note_prompt", DEFAULT_XIANYU_SEED_NOTE_PROMPT);
}}; }};
/** /**
@@ -933,11 +980,18 @@ public class SocialMediaServiceImpl implements ISocialMediaService
} }
/** /**
* 根据标题(+可选型号备注生成闲鱼文案代下单、教你下单不依赖JD接口 * 生成闲鱼文案:默认输出「代下单 + 教你下单」,可选额外生成「种草文案」。
*/ */
@Override @Override
public Map<String, Object> generateXianyuWenan(String title, String remark) { public Map<String, Object> generateXianyuWenan(Map<String, Object> request) {
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
Map<String, Object> req = request == null ? new HashMap<String, Object>() : request;
String title = asString(req.get("title"));
String remark = asString(req.get("remark"));
if (StringUtils.isEmpty(title)) {
// 兼容只传新字段的调用方式
title = asString(req.get("goods_title"));
}
if (StringUtils.isEmpty(title) || StringUtils.isEmpty(title.trim())) { if (StringUtils.isEmpty(title) || StringUtils.isEmpty(title.trim())) {
result.put("success", false); result.put("success", false);
result.put("error", "商品标题不能为空"); result.put("error", "商品标题不能为空");
@@ -946,6 +1000,22 @@ public class SocialMediaServiceImpl implements ISocialMediaService
String cleanTitle = cleanTitleOrRemark(title.trim()); String cleanTitle = cleanTitleOrRemark(title.trim());
String cleanRemark = StringUtils.isNotEmpty(remark) ? cleanTitleOrRemark(remark.trim()) : ""; String cleanRemark = StringUtils.isNotEmpty(remark) ? cleanTitleOrRemark(remark.trim()) : "";
boolean generateSeed = isTrue(req.get("generateSeedNote"));
if (generateSeed) {
if (StringUtils.isEmpty(asString(req.get("goods_title")))) {
req.put("goods_title", cleanTitle);
}
if (StringUtils.isEmpty(asString(req.get("goods_model")))) {
req.put("goods_model", cleanRemark);
}
String seedError = validateSeedRequiredFields(req);
if (seedError != null) {
result.put("success", false);
result.put("error", seedError);
return result;
}
}
String wenanBase = getPromptTemplateWithDefault("xianyu:wenan_base", DEFAULT_XIANYU_WENAN_BASE); String wenanBase = getPromptTemplateWithDefault("xianyu:wenan_base", DEFAULT_XIANYU_WENAN_BASE);
String jiaonixiadanExtra = getPromptTemplateWithDefault("xianyu:jiaonixiadan_extra", DEFAULT_XIANYU_JIAONIXIADAN_EXTRA); String jiaonixiadanExtra = getPromptTemplateWithDefault("xianyu:jiaonixiadan_extra", DEFAULT_XIANYU_JIAONIXIADAN_EXTRA);
@@ -973,9 +1043,113 @@ public class SocialMediaServiceImpl implements ISocialMediaService
result.put("success", true); result.put("success", true);
result.put("daixiadan", daixiadanBuilder.toString()); result.put("daixiadan", daixiadanBuilder.toString());
result.put("jiaonixiadan", jiaonixiadanBuilder.toString()); result.put("jiaonixiadan", jiaonixiadanBuilder.toString());
if (generateSeed) {
try {
String seedPrompt = buildSeedPrompt(req);
String seedNote = callJarvisLlm(seedPrompt, asString(req.get("profileId")));
if (StringUtils.isEmpty(seedNote)) {
throw new RuntimeException("种草文案生成返回为空");
}
result.put("seedNote", seedNote.trim());
} catch (Exception e) {
log.error("生成闲鱼种草文案失败", e);
result.put("seedNote", "");
result.put("seedNoteError", "种草文案生成失败: " + e.getMessage());
}
}
return result; return result;
} }
private String buildSeedPrompt(Map<String, Object> request) {
String template = getPromptTemplateWithDefault("xianyu:seed_note_prompt", DEFAULT_XIANYU_SEED_NOTE_PROMPT);
String goodsTitle = asString(request.get("goods_title"));
String goodsModel = asString(request.get("goods_model"));
String goodsType = asString(request.get("goods_type"));
String goodsBrand = asString(request.get("goods_brand"));
String channelSource = asString(request.get("channel_source"));
String officialPrice = asString(request.get("official_price"));
String sellPrice = asString(request.get("sell_price"));
String warranty = asString(request.get("warranty"));
String deliveryInstall = asString(request.get("delivery_install"));
String channelFinal = StringUtils.isNotEmpty(channelSource) ? channelSource : "品牌授权经销商直供";
String warrantyFinal = StringUtils.isNotEmpty(warranty) ? warranty : "品牌官方全国联保,和官方旗舰店享受同等售后权益";
String deliveryFinal = StringUtils.isNotEmpty(deliveryInstall) ? deliveryInstall : "全国包邮送货入户,乡镇可达";
String officialFinal = StringUtils.isNotEmpty(officialPrice) ? officialPrice : "比官方旗舰店优惠力度大";
String sellFinal = StringUtils.isNotEmpty(sellPrice) ? sellPrice : "私信询价(到手更划算)";
return template
.replace("{{goods_title}}", goodsTitle)
.replace("{{goods_model}}", goodsModel)
.replace("{{goods_type}}", goodsType)
.replace("{{goods_brand}}", StringUtils.isNotEmpty(goodsBrand) ? goodsBrand : "")
.replace("{{channel_source}}", channelFinal)
.replace("{{official_price}}", officialFinal)
.replace("{{sell_price}}", sellFinal)
.replace("{{warranty}}", warrantyFinal)
.replace("{{delivery_install}}", deliveryFinal);
}
private String validateSeedRequiredFields(Map<String, Object> request) {
if (request == null) {
return "请求体不能为空";
}
if (StringUtils.isEmpty(asString(request.get("goods_title")))) {
return "生成种草文案时goods_title 不能为空";
}
if (StringUtils.isEmpty(asString(request.get("goods_model")))) {
return "生成种草文案时goods_model 不能为空";
}
if (StringUtils.isEmpty(asString(request.get("goods_type")))) {
return "生成种草文案时goods_type 不能为空";
}
return null;
}
private String callJarvisLlm(String message, String profileId) {
String url = jarvisBaseUrl + "/jarvis/social-media/llm/test";
JSONObject body = new JSONObject();
body.put("message", message);
if (StringUtils.isNotEmpty(profileId)) {
body.put("profileId", profileId);
}
String resp = HttpUtils.sendJsonPost(url, body.toJSONString());
if (StringUtils.isEmpty(resp)) {
throw new RuntimeException("Jarvis 返回为空,请检查服务可达性");
}
Object parsed = JSON.parse(resp);
if (!(parsed instanceof JSONObject)) {
throw new RuntimeException("Jarvis 响应格式错误");
}
JSONObject jo = (JSONObject) parsed;
if (!Integer.valueOf(200).equals(jo.getInteger("code"))) {
throw new RuntimeException(jo.getString("msg"));
}
Object dataObj = jo.get("data");
if (!(dataObj instanceof JSONObject)) {
throw new RuntimeException("Jarvis data 结构异常");
}
JSONObject data = (JSONObject) dataObj;
if (!Boolean.TRUE.equals(data.getBoolean("success"))) {
throw new RuntimeException(data.getString("error"));
}
return data.getString("reply");
}
private static boolean isTrue(Object value) {
return Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(String.valueOf(value));
}
private static String asString(Object value) {
if (value == null) {
return "";
}
return StringUtils.trim(value.toString());
}
/** /**
* 清洗标题/型号中的敏感词(正则来自可配置模板 xianyu:title_clean_regex * 清洗标题/型号中的敏感词(正则来自可配置模板 xianyu:title_clean_regex
*/ */

View File

@@ -6,6 +6,10 @@ import com.ruoyi.jarvis.domain.TencentDocOperationLog;
import com.ruoyi.jarvis.mapper.TencentDocBatchPushRecordMapper; import com.ruoyi.jarvis.mapper.TencentDocBatchPushRecordMapper;
import com.ruoyi.jarvis.mapper.TencentDocOperationLogMapper; import com.ruoyi.jarvis.mapper.TencentDocOperationLogMapper;
import com.ruoyi.jarvis.service.ITencentDocBatchPushService; import com.ruoyi.jarvis.service.ITencentDocBatchPushService;
import com.ruoyi.jarvis.wecom.WxSendGoofishNotifyClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Resource; import javax.annotation.Resource;
@@ -18,6 +22,10 @@ import java.util.concurrent.TimeUnit;
@Service @Service
public class TencentDocBatchPushServiceImpl implements ITencentDocBatchPushService { public class TencentDocBatchPushServiceImpl implements ITencentDocBatchPushService {
private static final Logger log = LoggerFactory.getLogger(TencentDocBatchPushServiceImpl.class);
private static final String REDIS_STALE_BATCH_NOTIFY_KEY = "tendoc:batch:stale-notified:";
@Resource @Resource
private TencentDocBatchPushRecordMapper batchPushRecordMapper; private TencentDocBatchPushRecordMapper batchPushRecordMapper;
@@ -27,6 +35,13 @@ public class TencentDocBatchPushServiceImpl implements ITencentDocBatchPushServi
@Resource @Resource
private RedisCache redisCache; private RedisCache redisCache;
@Resource
private WxSendGoofishNotifyClient wxSendGoofishNotifyClient;
/** 仍为 RUNNING 超过该分钟数则归档为 INTERRUPTED可配置 */
@Value("${jarvis.tencent-doc.batch-push.stale-running-threshold-minutes:45}")
private int staleRunningThresholdMinutes;
private static final String DELAYED_PUSH_TASK_KEY = "tendoc:delayed_push:task_scheduled"; private static final String DELAYED_PUSH_TASK_KEY = "tendoc:delayed_push:task_scheduled";
private static final String DELAYED_PUSH_SCHEDULE_TIME_KEY = "tendoc:delayed_push:next_time"; private static final String DELAYED_PUSH_SCHEDULE_TIME_KEY = "tendoc:delayed_push:next_time";
@@ -81,6 +96,10 @@ public class TencentDocBatchPushServiceImpl implements ITencentDocBatchPushServi
@Override @Override
public TencentDocBatchPushRecord getBatchPushRecord(String batchId) { public TencentDocBatchPushRecord getBatchPushRecord(String batchId) {
TencentDocBatchPushRecord record = batchPushRecordMapper.selectByBatchId(batchId); TencentDocBatchPushRecord record = batchPushRecordMapper.selectByBatchId(batchId);
if (record != null && record.getFileId() != null && !record.getFileId().trim().isEmpty()) {
reconcileStaleRunningRecords(record.getFileId());
record = batchPushRecordMapper.selectByBatchId(batchId);
}
if (record != null) { if (record != null) {
// 加载关联的操作日志 // 加载关联的操作日志
List<TencentDocOperationLog> logs = operationLogMapper.selectLogsByBatchId(batchId); List<TencentDocOperationLog> logs = operationLogMapper.selectLogsByBatchId(batchId);
@@ -91,6 +110,8 @@ public class TencentDocBatchPushServiceImpl implements ITencentDocBatchPushServi
@Override @Override
public List<TencentDocBatchPushRecord> getBatchPushRecordListWithLogs(String fileId, String sheetId, Integer limit) { public List<TencentDocBatchPushRecord> getBatchPushRecordListWithLogs(String fileId, String sheetId, Integer limit) {
reconcileStaleRunningRecords(fileId);
TencentDocBatchPushRecord query = new TencentDocBatchPushRecord(); TencentDocBatchPushRecord query = new TencentDocBatchPushRecord();
query.setFileId(fileId); query.setFileId(fileId);
query.setSheetId(sheetId); query.setSheetId(sheetId);
@@ -156,5 +177,47 @@ public class TencentDocBatchPushServiceImpl implements ITencentDocBatchPushServi
return result; return result;
} }
@Override
public void reconcileStaleRunningRecords(String fileId) {
if (fileId == null || fileId.trim().isEmpty() || staleRunningThresholdMinutes <= 0) {
return;
}
Date before = new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(staleRunningThresholdMinutes));
List<TencentDocBatchPushRecord> stale = batchPushRecordMapper.selectRunningRecordsBefore(fileId, before);
if (stale == null || stale.isEmpty()) {
return;
}
for (TencentDocBatchPushRecord r : stale) {
String bid = r.getBatchId();
if (bid == null || bid.isEmpty()) {
continue;
}
TencentDocBatchPushRecord fresh = batchPushRecordMapper.selectByBatchId(bid);
if (fresh == null || !"RUNNING".equals(fresh.getStatus())) {
continue;
}
String resultMsg = String.format(
"任务已中断:超过 %d 分钟仍处于「执行中」(可能请求超时、进程退出或服务重启),系统已自动标记为结束。批次: %s",
staleRunningThresholdMinutes, bid);
String errMsg = "长时间未完成,自动归档为已中断";
try {
updateBatchPushRecord(bid, "INTERRUPTED", 0, 0, 0, resultMsg, errMsg);
log.warn("归档超时未结束的批量推送记录 batchId={} thresholdMinutes={}", bid, staleRunningThresholdMinutes);
} catch (Exception e) {
log.error("归档超时批量推送记录失败 batchId={}", bid, e);
continue;
}
String dedupeKey = REDIS_STALE_BATCH_NOTIFY_KEY + bid;
if (redisCache.getCacheObject(dedupeKey) != null) {
continue;
}
String pushText = "【腾讯文档推送】批次长时间未结束,已标记为「已中断」\n" + resultMsg;
boolean ok = wxSendGoofishNotifyClient.pushGoofishAgentText(null, "", pushText);
if (ok) {
redisCache.setCacheObject(dedupeKey, "1", 7, TimeUnit.DAYS);
}
}
}
} }

View File

@@ -1,7 +1,9 @@
package com.ruoyi.jarvis.service.impl; package com.ruoyi.jarvis.service.impl;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.jarvis.config.TencentDocConfig; import com.ruoyi.jarvis.config.TencentDocConfig;
import com.ruoyi.jarvis.wecom.WxSendGoofishNotifyClient;
import com.ruoyi.jarvis.service.ITencentDocBatchPushService; import com.ruoyi.jarvis.service.ITencentDocBatchPushService;
import com.ruoyi.jarvis.service.ITencentDocDelayedPushService; import com.ruoyi.jarvis.service.ITencentDocDelayedPushService;
import com.ruoyi.jarvis.service.ITencentDocTokenService; import com.ruoyi.jarvis.service.ITencentDocTokenService;
@@ -45,6 +47,9 @@ public class TencentDocDelayedPushServiceImpl implements ITencentDocDelayedPushS
@Autowired @Autowired
private ITencentDocBatchPushService batchPushService; private ITencentDocBatchPushService batchPushService;
@Autowired
private WxSendGoofishNotifyClient wxSendGoofishNotifyClient;
@Autowired @Autowired
private TencentDocConfig tencentDocConfig; private TencentDocConfig tencentDocConfig;
@@ -342,13 +347,25 @@ public class TencentDocDelayedPushServiceImpl implements ITencentDocDelayedPushS
Object result = method.invoke(controller, params); Object result = method.invoke(controller, params);
log.info("✓ 批量同步执行完成,结果: {}", result); log.info("✓ 批量同步执行完成,结果: {}", result);
if (result instanceof AjaxResult) {
AjaxResult ar = (AjaxResult) result;
if (!ar.isSuccess() && batchId != null) {
Object msgObj = ar.get(AjaxResult.MSG_TAG);
String msg = msgObj != null ? String.valueOf(msgObj) : "同步接口返回失败";
batchPushService.updateBatchPushRecord(batchId, "FAILED", 0, 0, 0, null, msg);
wxSendGoofishNotifyClient.pushGoofishAgentText(null, "",
"【腾讯文档推送】定时批量同步失败\n批次: " + batchId + "\n" + msg);
}
}
// 不再将 nextStartRow 写入 Redis下次定时执行时从接口获取 rowCount 决定范围 // 不再将 nextStartRow 写入 Redis下次定时执行时从接口获取 rowCount 决定范围
} catch (Exception ex) { } catch (Exception ex) {
log.error("批量同步调用失败", ex); log.error("批量同步调用失败", ex);
if (batchId != null) { if (batchId != null) {
batchPushService.updateBatchPushRecord(batchId, "FAILED", 0, 0, 0, String msg = "批量同步调用失败: " + (ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName());
null, "批量同步调用失败: " + ex.getMessage()); batchPushService.updateBatchPushRecord(batchId, "FAILED", 0, 0, 0, null, msg);
wxSendGoofishNotifyClient.pushGoofishAgentText(null, "",
"【腾讯文档推送】定时批量同步异常\n批次: " + batchId + "\n" + msg);
} }
} }
@@ -357,8 +374,10 @@ public class TencentDocDelayedPushServiceImpl implements ITencentDocDelayedPushS
// 更新批量推送记录为失败状态 // 更新批量推送记录为失败状态
if (batchId != null) { if (batchId != null) {
try { try {
batchPushService.updateBatchPushRecord(batchId, "FAILED", 0, 0, 0, String msg = "执行批量同步失败: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
null, "执行批量同步失败: " + e.getMessage()); batchPushService.updateBatchPushRecord(batchId, "FAILED", 0, 0, 0, null, msg);
wxSendGoofishNotifyClient.pushGoofishAgentText(null, "",
"【腾讯文档推送】定时批量同步异常\n批次: " + batchId + "\n" + msg);
} catch (Exception ex) { } catch (Exception ex) {
log.error("更新批量推送记录失败", ex); log.error("更新批量推送记录失败", ex);
} }

View File

@@ -229,6 +229,8 @@ public class TencentDocServiceImpl implements ITencentDocService {
companyColumn = i; companyColumn = i;
} else if (TencentDocDataParser.headerEquals(cellText, "单号")) { } else if (TencentDocDataParser.headerEquals(cellText, "单号")) {
orderNoColumn = i; orderNoColumn = i;
} else if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellText, "客户单号")) {
orderNoColumn = i;
} else if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellText, "第三方单号")) { } else if (orderNoColumn == null && TencentDocDataParser.headerEquals(cellText, "第三方单号")) {
orderNoColumn = i; orderNoColumn = i;
} else if (cellText.contains("型号")) { } else if (cellText.contains("型号")) {
@@ -255,7 +257,7 @@ public class TencentDocServiceImpl implements ITencentDocService {
} }
if (orderNoColumn == null) { if (orderNoColumn == null) {
throw new RuntimeException("未找到'单号'列,请检查表头配置"); throw new RuntimeException("未找到「单号」「客户单号」或「第三方单号」列,请检查表头配置");
} }
log.info("表头识别完成 - 单号列: {}, 京东下单订单号列: {}, 物流列: {}", orderNoColumn, jdPlaceOrderNoColumn, logisticsColumn); log.info("表头识别完成 - 单号列: {}, 京东下单订单号列: {}, 物流列: {}", orderNoColumn, jdPlaceOrderNoColumn, logisticsColumn);

View File

@@ -15,7 +15,9 @@ import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -23,8 +25,9 @@ import java.util.regex.Pattern;
/** /**
* LinPingFan全部指令其他人员须在超级管理员中识别为本人wxid=企微 UserID**或** 企微 UserID 出现在 touser 逗号分隔列表中),且仅「京*」指令 + 京东分享物流链接流程; * LinPingFan全部指令其他人员须在超级管理员中识别为本人wxid=企微 UserID**或** 企微 UserID 出现在 touser 逗号分隔列表中),且仅「京*」指令 + 京东分享物流链接流程;
* 例外:以「单」或「开始」开头且含「分销标记」的录单正文优先于物流(不进入 3.cn 多轮、不占用物流监听)。 * 例外:以「单」或「开始」开头且含「分销标记」的录单正文优先于物流(不进入 3.cn 多轮、不占用物流监听)。
* 以「开」开头且正文含 11 位手机号1 开头POST 配置项 jarvis.phone-forward 指向的局域网服务,回显 reply_text。 * 以「开」或「慢开」开头且正文含 11 位手机号1 开头POST 配置项 jarvis.phone-forward 指向的局域网服务,回显 reply_textbody 含对应 bot
* 多轮会话使用 Redis{@link WeComChatSession},键 interaction_state:wecom:{FromUserName}与旧版「开通礼金」interaction_state 思路一致。 * 多轮会话使用 Redis{@link WeComChatSession},键 interaction_state:wecom:{FromUserName}与旧版「开通礼金」interaction_state 思路一致。
* 回复正文按 UTF-8 每段至多 2048 字节拆分:首段被动回复,其余主动推送(同一次用户消息、不重复触发查询)。
*/ */
@Service @Service
public class WeComInboundServiceImpl implements IWeComInboundService { public class WeComInboundServiceImpl implements IWeComInboundService {
@@ -34,8 +37,9 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
public static final String WE_COM_SUPER_USER_ID = "LinPingFan"; public static final String WE_COM_SUPER_USER_ID = "LinPingFan";
private static final Pattern JD_3CN = Pattern.compile("https://3\\.cn/[A-Za-z0-9\\-]+"); private static final Pattern JD_3CN = Pattern.compile("https://3\\.cn/[A-Za-z0-9\\-]+");
private static final int REPLY_MAX_LEN = 3500;
private static final String REPLY_TRUNCATED_HINT = "\n…\n内容过长余下部分已省略"; /** 企微被动回复与应用文本消息 content 官方上限UTF-8 字节(见被动回复 / 发送应用消息文档) */
private static final int WE_COM_TEXT_MAX_UTF8_BYTES = 2048;
/** 无超级管理员配置 */ /** 无超级管理员配置 */
private static String replyPermissionDenied() { private static String replyPermissionDenied() {
@@ -52,8 +56,9 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
+ "当前账号支持:\n" + "当前账号支持:\n"
+ "· 「京」开头的统计、订单类指令(可先发「京菜单」查看列表)\n" + "· 「京」开头的统计、订单类指令(可先发「京菜单」查看列表)\n"
+ "· 含 3.cn 的京东物流分享:先发链接,再发备注\n" + "· 含 3.cn 的京东物流分享:先发链接,再发备注\n"
+ "· 京外物列表 / 京外物删 — 查询或删除外部分享链物流登记\n"
+ "· 以「单」或「开始」开头,且含「分销标记」的录单正文\n" + "· 以「单」或「开始」开头,且含「分销标记」的录单正文\n"
+ "· 以「开」开头且含手机号的查询\n\n" + "· 以「开」或「慢开」开头且含手机号的查询\n\n"
+ "如需其他指令,请联系管理员。"; + "如需其他指令,请联系管理员。";
} }
@@ -75,7 +80,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
/** 备注已提交并入队 */ /** 备注已提交并入队 */
private static String replyLogisticsRemarkDone() { private static String replyLogisticsRemarkDone() {
return "「物流备注」已登记\n\n" return "「物流备注」已登记\n\n"
+ "已加入查询队列,约每 10 分钟扫描并推送结果(与订单物流任务一致)。\n" + "已加入查询队列,约每 15 分钟扫描并推送结果(与订单物流任务一致)。\n"
+ "本轮录入已结束,可继续发录单、指令或新的物流链接。"; + "本轮录入已结束,可继续发录单、指令或新的物流链接。";
} }
@@ -106,7 +111,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
String openPhoneReply = openPhoneForwardService.tryReply(content); String openPhoneReply = openPhoneForwardService.tryReply(content);
if (openPhoneReply != null) { if (openPhoneReply != null) {
return WeComInboundResult.passiveOnly(truncateReply(openPhoneReply)); return toChunkedInboundResult(openPhoneReply);
} }
final boolean danRecordPriority = isDanRecordPriorityOverLogistics(content); final boolean danRecordPriority = isDanRecordPriorityOverLogistics(content);
@@ -163,33 +168,89 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
if (!isSuper) { if (!isSuper) {
String cmd = content.trim().replaceFirst("^\uFEFF", ""); String cmd = content.trim().replaceFirst("^\uFEFF", "");
if (!cmd.startsWith("") && !danRecordPriority && !cmd.startsWith("")) { if (!cmd.startsWith("") && !danRecordPriority && !cmd.startsWith("") && !cmd.startsWith("慢开")) {
return WeComInboundResult.passiveOnly(replyGeneralUserScopeHint()); return WeComInboundResult.passiveOnly(replyGeneralUserScopeHint());
} }
} }
List<String> parts = instructionService.execute(content, false, isSuper); List<String> parts = instructionService.execute(content, false, isSuper, from);
if (parts == null || parts.isEmpty()) { if (parts == null || parts.isEmpty()) {
return WeComInboundResult.empty(); return WeComInboundResult.empty();
} }
if (parts.size() == 1) { if (parts.size() == 1) {
return WeComInboundResult.passiveOnly(truncateReply(parts.get(0))); return toChunkedInboundResult(parts.get(0));
} }
List<String> headChunks = splitUtf8Chunks(parts.get(0), WE_COM_TEXT_MAX_UTF8_BYTES);
String passive = headChunks.isEmpty() ? "" : headChunks.get(0);
List<String> active = new ArrayList<>(); List<String> active = new ArrayList<>();
for (int i = 1; i < parts.size(); i++) { for (int h = 1; h < headChunks.size(); h++) {
active.add(truncateReply(parts.get(i))); active.add(headChunks.get(h));
} }
return new WeComInboundResult(truncateReply(parts.get(0)), active); for (int i = 1; i < parts.size(); i++) {
String p = parts.get(i);
if (p == null) {
continue;
}
for (String chunk : splitUtf8Chunks(p, WE_COM_TEXT_MAX_UTF8_BYTES)) {
active.add(chunk);
}
}
return new WeComInboundResult(passive, active);
} }
private static String truncateReply(String reply) { /**
if (reply == null) { * 首段 ≤2048 UTF-8 字节走被动回复,其余走 wxSend 主动推送(同一次用户消息内顺序下发,不重复计费)。
return ""; */
private static WeComInboundResult toChunkedInboundResult(String fullText) {
List<String> chunks = splitUtf8Chunks(fullText, WE_COM_TEXT_MAX_UTF8_BYTES);
if (chunks.isEmpty()) {
return WeComInboundResult.passiveOnly("");
} }
if (reply.length() > REPLY_MAX_LEN) { if (chunks.size() == 1) {
return reply.substring(0, REPLY_MAX_LEN) + REPLY_TRUNCATED_HINT; return WeComInboundResult.passiveOnly(chunks.get(0));
} }
return reply; return new WeComInboundResult(chunks.get(0), new ArrayList<>(chunks.subList(1, chunks.size())));
}
/**
* 按 UTF-8 字节长度切分,每段不超过 maxUtf8Bytes不在 BMP 的码点按整字符保留)。
*/
private static List<String> splitUtf8Chunks(String text, int maxUtf8Bytes) {
if (text == null) {
return Collections.singletonList("");
}
if (text.isEmpty()) {
return Collections.singletonList("");
}
if (maxUtf8Bytes < 1) {
throw new IllegalArgumentException("maxUtf8Bytes must be >= 1");
}
List<String> out = new ArrayList<>();
int i = 0;
final int n = text.length();
while (i < n) {
int chunkStart = i;
int usedBytes = 0;
while (i < n) {
int cp = text.codePointAt(i);
int charCount = Character.charCount(cp);
int b = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8).length;
if (usedBytes + b > maxUtf8Bytes) {
break;
}
usedBytes += b;
i += charCount;
}
if (i == chunkStart) {
int cp = text.codePointAt(i);
int charCount = Character.charCount(cp);
out.add(text.substring(chunkStart, chunkStart + charCount));
i = chunkStart + charCount;
} else {
out.add(text.substring(chunkStart, i));
}
}
return out;
} }
/** /**

View File

@@ -47,6 +47,32 @@ public class WeComShareLinkLogisticsJobServiceImpl implements IWeComShareLinkLog
return weComShareLinkLogisticsJobMapper.selectWeComShareLinkLogisticsJobList(query); return weComShareLinkLogisticsJobMapper.selectWeComShareLinkLogisticsJobList(query);
} }
@Override
public List<WeComShareLinkLogisticsJob> selectRecentForInstruction(String remarkKeyword, int days, int limit) {
int d = Math.max(1, Math.min(days, 365));
int lim = Math.max(1, Math.min(limit, 100));
String kw =
remarkKeyword != null && !remarkKeyword.trim().isEmpty() ? remarkKeyword.trim() : null;
return weComShareLinkLogisticsJobMapper.selectRecentForInstruction(kw, d, lim);
}
@Override
public int deleteByJobKey(String jobKey) {
if (jobKey == null || !StringUtils.hasText(jobKey.trim())) {
return 0;
}
return weComShareLinkLogisticsJobMapper.deleteByJobKey(jobKey.trim());
}
@Override
public int deleteByRemarkAndTrackingUrl(String remark, String trackingUrl) {
if (!StringUtils.hasText(trackingUrl)) {
return 0;
}
String r = remark != null ? remark : "";
return weComShareLinkLogisticsJobMapper.deleteByRemarkAndTrackingUrl(r, trackingUrl.trim());
}
@Override @Override
public Map<String, Object> backfillImportedFromInboundTrace() { public Map<String, Object> backfillImportedFromInboundTrace() {
int imported = 0; int imported = 0;

View File

@@ -33,6 +33,11 @@ public class WxSendServiceImpl implements IWxSendService {
logger.info("微信推送服务健康检查地址已初始化: {}", healthCheckUrl); logger.info("微信推送服务健康检查地址已初始化: {}", healthCheckUrl);
} }
@Override
public String getHealthCheckServiceUrl() {
return healthCheckUrl;
}
@Override @Override
public IWxSendService.HealthCheckResult checkHealth() { public IWxSendService.HealthCheckResult checkHealth() {
try { try {

View File

@@ -0,0 +1,71 @@
package com.ruoyi.jarvis.task;
import com.ruoyi.jarvis.config.JarvisGoofishProperties;
import com.ruoyi.jarvis.domain.ErpOpenConfig;
import com.ruoyi.jarvis.service.IErpGoofishOrderService;
import com.ruoyi.jarvis.service.IErpOpenConfigService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
/**
* 闲管家 ERP定时拉单与运单同步/自动发货
*/
@Component
public class GoofishScheduledTasks {
private static final Logger log = LoggerFactory.getLogger(GoofishScheduledTasks.class);
@Resource
private IErpOpenConfigService erpOpenConfigService;
@Resource
private IErpGoofishOrderService erpGoofishOrderService;
@Resource
private JarvisGoofishProperties goofishProperties;
@Scheduled(cron = "${jarvis.goofish-order.pull-cron:0 * * * * ?}")
public void scheduledPull() {
List<ErpOpenConfig> cfgs = erpOpenConfigService.selectEnabledOrderBySort();
if (cfgs == null || cfgs.isEmpty()) {
return;
}
int hours = goofishProperties.getPullLookbackHours();
int n = 0;
for (ErpOpenConfig c : cfgs) {
try {
n += erpGoofishOrderService.pullOrdersForAppKey(c.getAppKey(), hours);
} catch (Exception e) {
log.warn("定时拉单失败 appKey={} {}", c.getAppKey(), e.getMessage());
}
}
if (n > 0) {
log.info("闲管家定时拉单本轮处理约 {} 条子订单项", n);
try {
int synced = erpGoofishOrderService.syncWaybillAndTryShipBatch(goofishProperties.getAutoShipBatchSize());
if (synced > 0) {
log.info("拉单后轮询:运单同步/自动发货扫描处理 {} 条", synced);
}
} catch (Exception syncEx) {
log.warn("拉单后轮询运单同步/发货扫描异常 {}", syncEx.getMessage());
}
}
}
@Scheduled(cron = "${jarvis.goofish-order.auto-ship-cron:0 2/10 * * * ?}")
public void scheduledWaybillAndShip() {
try {
int k = erpGoofishOrderService.syncWaybillAndTryShipBatch(goofishProperties.getAutoShipBatchSize());
if (k > 0) {
log.info("闲管家运单同步与发货扫描处理 {} 条", k);
}
} catch (Exception e) {
log.warn("闲管家自动发货扫描异常 {}", e.getMessage());
}
}
}

View File

@@ -5,15 +5,18 @@ import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.ILogisticsService; import com.ruoyi.jarvis.service.ILogisticsService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* 物流信息扫描定时任务 * 物流信息扫描定时任务
* 每10分钟扫描一次分销标记为F或PDD的订单最近30天获取物流信息并推送;结束后处理企微分享链 adhoc 队列 * 按配置周期(默认每 20 分钟扫描分销标记为 F/PDD的订单(最近 30 天),拉物流并推送;
* 结束后处理企微分享链 adhoc 队列。
*/ */
@Component @Component
public class LogisticsScanTask { public class LogisticsScanTask {
@@ -25,11 +28,18 @@ public class LogisticsScanTask {
@Resource @Resource
private ILogisticsService logisticsService; private ILogisticsService logisticsService;
/** 每条订单处理后的间隔(毫秒),减轻下游物流 HTTP 压力0 表示不睡眠 */
@Value("${jarvis.server.logistics.scan.order-delay-ms:250}")
private long orderDelayMs;
/** 单轮最多处理的订单数0 表示不限制(候选很多时可限制单轮耗时) */
@Value("${jarvis.server.logistics.scan.max-orders-per-round:0}")
private int maxOrdersPerRound;
/** /**
* 定时任务每10分钟执行一次与 @Scheduled 中 cron 一致) * 只扫描最近 30 天的订单SQL 固定);周期与单轮上限见 jarvis.server.logistics.scan.*
* 只扫描最近30天的订单
*/ */
@Scheduled(cron = "0 */10 * * * ?") @Scheduled(cron = "${jarvis.server.logistics.scan.cron:0 */20 * * * ?}")
public void scanAndFetchLogistics() { public void scanAndFetchLogistics() {
long t0 = System.currentTimeMillis(); long t0 = System.currentTimeMillis();
int orderCandidates = 0; int orderCandidates = 0;
@@ -47,8 +57,14 @@ public class LogisticsScanTask {
if (orders == null || orders.isEmpty()) { if (orders == null || orders.isEmpty()) {
logger.info("订单扫描:候选 0 条最近30天 F/PDD 有物流链)"); logger.info("订单扫描:候选 0 条最近30天 F/PDD 有物流链)");
} else { } else {
int totalFromDb = orders.size();
if (maxOrdersPerRound > 0 && orders.size() > maxOrdersPerRound) {
logger.info("订单扫描:库中候选 {} 条,本轮按 max-orders-per-round={} 仅处理前 {} 条(余下轮次继续)",
totalFromDb, maxOrdersPerRound, maxOrdersPerRound);
orders = new ArrayList<>(orders.subList(0, maxOrdersPerRound));
}
orderCandidates = orders.size(); orderCandidates = orders.size();
logger.info("订单扫描:候选 {} 条最近30天 F/PDD 有物流链)", orderCandidates); logger.info("订单扫描:本轮处理列表 {} 条最近30天 F/PDD 有物流链)", orderCandidates);
for (JDOrder order : orders) { for (JDOrder order : orders) {
try { try {
@@ -57,7 +73,7 @@ public class LogisticsScanTask {
continue; continue;
} }
logger.info("订单扫描:处理中 id={} orderId={} mark={}", logger.debug("订单扫描:处理中 id={} orderId={} mark={}",
order.getId(), order.getOrderId(), order.getDistributionMark()); order.getId(), order.getOrderId(), order.getDistributionMark());
boolean success = logisticsService.fetchLogisticsAndPush(order); boolean success = logisticsService.fetchLogisticsAndPush(order);
@@ -70,7 +86,9 @@ public class LogisticsScanTask {
logger.warn("订单扫描:未成功 id={} orderId={}", order.getId(), order.getOrderId()); logger.warn("订单扫描:未成功 id={} orderId={}", order.getId(), order.getOrderId());
} }
Thread.sleep(500); if (orderDelayMs > 0) {
Thread.sleep(orderDelayMs);
}
} catch (InterruptedException e) { } catch (InterruptedException e) {
logger.error("定时任务被中断", e); logger.error("定时任务被中断", e);
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();

View File

@@ -0,0 +1,474 @@
package com.ruoyi.jarvis.wecom;
import com.alibaba.fastjson2.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* 调用 wxSend 闲鱼自建应用文本推送,与 /wx/send/pdd 一致Header vanToken + JSONtitle、text、touser
*/
@Component
public class WxSendGoofishNotifyClient {
private static final Logger log = LoggerFactory.getLogger(WxSendGoofishNotifyClient.class);
/** 与企微 message/send 文本 content 上限对齐,预留余量 */
private static final int CONTENT_MAX = 2000;
@Value("${jarvis.wecom.wxsend-base-url:}")
private String wxsendBaseUrl;
/** 与 wxSend TokenUtil 校验一致,请求头 vanToken */
@Value("${jarvis.wecom.wxsend-van-token:}")
private String wxsendVanToken;
/**
* 接收通知的成员 UserID企微管理后台可见多个用英文逗号或 | 分隔;
* 发往 wxSend 时会规范为 touser 的 user1|user2 形式。留空表示不推送。
*/
@Value("${jarvis.wecom.goofish-notify-touser:}")
private String goofishNotifyTouser;
/**
* 闲鱼订单事件通知 wxSend由 wxSend 转企微应用消息)。
*
* @param orderNo 闲鱼订单号
* @param eventType ORDER_SYNC / LOGISTICS_SYNC / SHIP
* @param source NOTIFY、LIST、DETAIL_REFRESH 等
* @param message 已截断的说明文案
*/
public void notifyGoofishEvent(String orderNo, String eventType, String source, String message) {
if (!StringUtils.hasText(wxsendBaseUrl) || !StringUtils.hasText(wxsendVanToken)) {
return;
}
String toUser = buildTouserParam(goofishNotifyTouser);
if (!StringUtils.hasText(toUser)) {
return;
}
String content = buildContent(orderNo, eventType, source, message);
if (!StringUtils.hasText(content)) {
return;
}
try {
String base = normalizeBase(wxsendBaseUrl);
String url = base + "/wx/send/goofish";
postMessageReturnsOk(url, wxsendVanToken, "", content, toUser);
} catch (Exception e) {
log.warn("wxSend /wx/send/goofish 失败 orderNo={} err={}", orderNo, e.toString());
}
}
/**
* 京东物流扫描等:发往企微「闲鱼」自建应用,请求体与 PDD 推送一致title + text + touser
*
* @param optionalToUser 非空时用与 logistics.push.touser.* 相同的成员 ID逗号/| 亦可);空则用 goofish-notify-touser
* @param title 与 {@code LogisticsServiceImpl} 推 PDD 时的 title 一致,可为空
* @param textBody 正文(对应 PDD 的 text
* @return wxSend 业务 code=200 为 true未配置 token 或 touser 为空返回 false
*/
public boolean pushGoofishAgentText(String optionalToUser, String title, String textBody) {
if (!StringUtils.hasText(wxsendBaseUrl) || !StringUtils.hasText(wxsendVanToken)) {
log.debug("wxSend 闲鱼应用物流推送跳过:未配置 wxsend-base-url 或 wxsend-van-token");
return false;
}
String rawTouser = StringUtils.hasText(optionalToUser) ? optionalToUser : goofishNotifyTouser;
String toUser = buildTouserParam(rawTouser);
if (!StringUtils.hasText(toUser)) {
log.warn("wxSend 闲鱼应用物流推送跳过:无接收人(请配置 jarvis.wecom.goofish-notify-touser 或 logistics.push.touser");
return false;
}
String t = title != null ? title : "";
String body = textBody != null ? textBody : "";
if (t.length() > CONTENT_MAX) {
t = t.substring(0, CONTENT_MAX - 1) + "";
body = "";
} else {
int overhead = StringUtils.hasText(t) ? t.length() + 1 : 0;
int maxBody = Math.max(0, CONTENT_MAX - overhead);
if (body.length() > maxBody) {
body = body.substring(0, Math.max(0, maxBody - 1)) + "";
}
}
try {
String base = normalizeBase(wxsendBaseUrl);
String url = base + "/wx/send/goofish";
return postMessageReturnsOk(url, wxsendVanToken, t, body, toUser);
} catch (Exception e) {
log.warn("wxSend /wx/send/goofish 物流全文失败 err={}", e.toString());
return false;
}
}
/**
* 企微短通知:场景标题 + 订单号 + 必要字段行(不再展示数据来源等冗长前缀)。
*/
private static String buildContent(String orderNo, String eventType, String source, String message) {
String on = orderNo != null ? orderNo : "-";
String head = resolveNotifyHeadline(eventType, message);
String detail = normalizeNotifyDetailLines(eventType, message);
StringBuilder sb = new StringBuilder();
sb.append(head).append("\n订单号").append(on).append('\n');
if (StringUtils.hasText(detail)) {
sb.append(detail);
}
String s = sb.toString();
if (s.length() > CONTENT_MAX) {
return s.substring(0, CONTENT_MAX - 1) + "";
}
return s;
}
private static String resolveNotifyHeadline(String eventType, String message) {
String m = message != null ? message : "";
String t = eventType != null ? eventType.trim() : "";
if ("SHIP".equals(t)) {
if (m.contains("失败") || m.contains("异常") || m.contains("缺地址")) {
return "发货失败";
}
return "发货";
}
if ("LOGISTICS_SYNC".equals(t)) {
return "物流";
}
if ("ORDER_SYNC".equals(t)) {
if (m.startsWith("新订单") || m.contains("新订单入库")) {
return "新订单";
}
if (looksRefundOnlyDiffLine(m)) {
return "退款";
}
return "订单";
}
return StringUtils.hasText(t) ? t : "订单";
}
/** ORDER_SYNC 正文里是否仅有退款状态变更(单行或多行中的唯一语义块) */
private static boolean looksRefundOnlyDiffLine(String message) {
if (!StringUtils.hasText(message) || message.contains("新订单")) {
return false;
}
String normalized = message.replace('', '\n').trim();
String[] lines = normalized.split("\n");
boolean sawRefund = false;
boolean sawOrder = false;
for (String raw : lines) {
String line = raw.trim();
if (line.isEmpty()) {
continue;
}
if (line.startsWith("退款状态")) {
sawRefund = true;
continue;
}
if (line.startsWith("订单状态")) {
sawOrder = true;
continue;
}
return false;
}
return sawRefund && !sawOrder;
}
/**
* 将内部摘要句转为「字段:值」行;箭头统一为「 → 」。
*/
private static String normalizeNotifyDetailLines(String eventType, String message) {
if (!StringUtils.hasText(message)) {
return "";
}
String t = eventType != null ? eventType.trim() : "";
String m = message.trim();
if ("SHIP".equals(t)) {
if (m.startsWith("发货成功")) {
StringBuilder details = new StringBuilder();
String wb = extractCommaField(m, "运单 ");
if (StringUtils.hasText(wb)) {
details.append("物流单号:").append(normalizeArrowSpaces(wb)).append('\n');
}
String os = extractCommaField(m, "订单状态 ");
if (StringUtils.hasText(os)) {
details.append("订单状态:").append(normalizeArrowSpaces(os)).append('\n');
}
String rs = extractCommaField(m, "退款状态 ");
if (StringUtils.hasText(rs)) {
details.append("退款状态:").append(normalizeArrowSpaces(rs)).append('\n');
}
if (details.length() > 0) {
return details.toString();
}
}
if (m.startsWith("发货失败") || m.startsWith("发货异常")) {
int idx = m.indexOf('');
String reason = idx >= 0 && idx < m.length() - 1 ? m.substring(idx + 1).trim() : m;
return "原因:" + normalizeArrowSpaces(reason) + "\n";
}
}
if ("ORDER_SYNC".equals(t)) {
// 新订单入库,订单状态 X退款状态 Y
if (m.contains("新订单入库") && m.contains("订单状态 ") && m.contains("退款状态 ")) {
String os = substringBetweenPrefixes(m, "订单状态 ", ",退款状态");
String rs = substringAfterPrefix(m, "退款状态 ");
StringBuilder sb = new StringBuilder();
if (StringUtils.hasText(os)) {
sb.append("订单状态:").append(os.trim()).append('\n');
}
if (StringUtils.hasText(rs)) {
sb.append("退款状态:").append(rs.trim()).append('\n');
}
return sb.toString();
}
// 订单状态 A → B退款状态 …(仅输出消息里出现的块)
return formatOrderSyncSemicolonParts(m);
}
if ("LOGISTICS_SYNC".equals(t)) {
return formatLogisticsParts(m);
}
return normalizeArrowSpaces(m) + "\n";
}
private static String formatOrderSyncSemicolonParts(String m) {
String[] parts = m.split("");
StringBuilder sb = new StringBuilder();
for (String p : parts) {
String line = kvLineOrderOrRefund(p.trim());
if (line != null) {
sb.append(line).append('\n');
}
}
return sb.toString();
}
private static String kvLineOrderOrRefund(String segment) {
if (!StringUtils.hasText(segment)) {
return null;
}
if (segment.startsWith("订单状态 ")) {
return "订单状态:" + normalizeArrowSpaces(segment.substring("订单状态 ".length()).trim());
}
if (segment.startsWith("退款状态 ")) {
return "退款状态:" + normalizeArrowSpaces(segment.substring("退款状态 ".length()).trim());
}
return null;
}
private static String formatLogisticsParts(String m) {
String[] parts = m.split("");
StringBuilder sb = new StringBuilder();
for (String p : parts) {
String line = logisticsSegmentToKv(p.trim());
if (line != null) {
sb.append(line).append('\n');
}
}
return sb.toString();
}
/** 本地/平台运单 → 对用户展示为物流单号;其它变更行仍可保留技术性标签 */
private static String logisticsSegmentToKv(String segment) {
if (!StringUtils.hasText(segment)) {
return null;
}
if (segment.startsWith("本地运单 ") || segment.startsWith("平台运单 ")) {
int cut = segment.indexOf(' ');
String rest = normalizeArrowSpaces(segment.substring(cut + 1).trim());
return "物流单号:" + rest;
}
String kv = logisticLabelToColon(segment);
return kv != null ? kv : (normalizeArrowSpaces(segment));
}
private static String logisticLabelToColon(String segment) {
String[] prefixes = {"快递编码 ", "快递名称 "};
for (String pref : prefixes) {
if (segment.startsWith(pref)) {
String label = pref.trim();
return label.substring(0, label.length() - 1) + ""
+ normalizeArrowSpaces(segment.substring(pref.length()).trim());
}
}
return null;
}
private static String normalizeArrowSpaces(String s) {
if (s == null) {
return "";
}
return s.replace("", "").replace("", "").replace("", "");
}
/** 从「片段1片段2…」中取出以 {@code fieldPrefix} 开头的片段值(去掉前缀) */
private static String extractCommaField(String m, String fieldPrefix) {
if (!StringUtils.hasText(m) || !StringUtils.hasText(fieldPrefix)) {
return null;
}
for (String part : m.split("")) {
String p = part.trim();
if (p.startsWith(fieldPrefix)) {
String v = p.substring(fieldPrefix.length()).trim();
return v.length() > 0 ? v : null;
}
}
return null;
}
private static String substringBetweenPrefixes(String m, String a, String b) {
int ia = m.indexOf(a);
if (ia < 0) {
return null;
}
int ib = m.indexOf(b, ia + a.length());
if (ib < 0) {
return null;
}
return m.substring(ia + a.length(), ib);
}
private static String substringAfterPrefix(String m, String pref) {
int i = m.indexOf(pref);
if (i < 0) {
return null;
}
return m.substring(i + pref.length()).trim();
}
/**
* 逗号或 | 分隔的 UserID → 企微 API 要求的 user1|user2
*/
private static String buildTouserParam(String raw) {
if (!StringUtils.hasText(raw)) {
return null;
}
String[] parts = raw.split("[,|]");
List<String> ids = new ArrayList<>();
for (String p : parts) {
String t = p == null ? "" : p.trim();
if (t.length() > 0) {
ids.add(t);
}
}
if (ids.isEmpty()) {
return null;
}
return String.join("|", ids);
}
private static String normalizeBase(String base) {
String b = base.trim();
if (b.endsWith("/")) {
return b.substring(0, b.length() - 1);
}
return b;
}
/**
* 服务监控展示:闲鱼企微应用推送接口完整 URL不发起请求
*/
public String getGoofishPushEndpointDisplay() {
if (!StringUtils.hasText(wxsendBaseUrl)) {
return "";
}
return normalizeBase(wxsendBaseUrl) + "/wx/send/goofish";
}
/**
* 服务监控手动测试:经 wxSend 向企微「闲鱼」应用发一条文本。
*
* @return null 表示 HTTP 2xx 且 wxSend 返回 code=200非空为可直接展示的失败原因
*/
public String testGoofishNotify() {
if (!StringUtils.hasText(wxsendBaseUrl)) {
return "未配置 jarvis.wecom.wxsend-base-url";
}
if (!StringUtils.hasText(wxsendVanToken)) {
return "未配置 jarvis.wecom.wxsend-van-token须与 wxSend TokenUtil 一致)";
}
if (!StringUtils.hasText(goofishNotifyTouser)) {
return "未配置 jarvis.wecom.goofish-notify-touser接收人为空";
}
String content = "【服务监控·闲鱼通知测试】RuoYi 手动触发 " + new java.util.Date();
boolean ok = pushGoofishAgentText(null, "", content);
if (ok) {
return null;
}
return "推送未成功:请核对 wxSend 服务、vanToken、企微闲鱼应用及接收人或查看服务端日志";
}
/**
* POST {@code /wx/send/goofish}body 与 {@code /wx/send/pdd} 相同字段名title、text、touser
*/
private boolean postMessageReturnsOk(String url, String vanToken, String title, String text, String touserPipeJoined)
throws Exception {
JSONObject body = new JSONObject();
body.put("title", title != null ? title : "");
body.put("text", text != null ? text : "");
body.put("touser", touserPipeJoined);
byte[] bytes = body.toJSONString().getBytes(StandardCharsets.UTF_8);
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(15000);
conn.setReadTimeout(60000);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
conn.setRequestProperty("vanToken", vanToken);
try (OutputStream os = conn.getOutputStream()) {
os.write(bytes);
}
int httpCode = conn.getResponseCode();
InputStream is = httpCode >= 200 && httpCode < 300 ? conn.getInputStream() : conn.getErrorStream();
String resp = readAll(is);
if (httpCode < 200 || httpCode >= 300) {
log.warn("wxSend /wx/send/goofish HTTP {} body={}", httpCode, resp);
return false;
}
Integer bizCode = null;
try {
JSONObject jo = JSONObject.parseObject(resp);
if (jo != null) {
bizCode = jo.getInteger("code");
}
} catch (Exception parseEx) {
log.debug("解析 wxSend 响应: {}", parseEx.toString());
}
if (bizCode != null && bizCode == 200) {
log.debug("wxSend /wx/send/goofish OK resp={}", resp);
return true;
}
log.warn("wxSend /wx/send/goofish 业务未成功 resp={}", resp);
return false;
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
private static String readAll(InputStream is) throws java.io.IOException {
if (is == null) {
return "";
}
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int n;
while ((n = is.read(buf)) >= 0) {
sb.append(new String(buf, 0, n, StandardCharsets.UTF_8));
}
return sb.toString();
}
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.jarvis.mapper.ErpGoofishOrderEventLogMapper">
<resultMap id="ErpGoofishOrderEventLogResult" type="com.ruoyi.jarvis.domain.ErpGoofishOrderEventLog">
<id property="id" column="id"/>
<result property="orderId" column="order_id"/>
<result property="appKey" column="app_key"/>
<result property="orderNo" column="order_no"/>
<result property="eventType" column="event_type"/>
<result property="source" column="source"/>
<result property="message" column="message"/>
<result property="createTime" column="create_time"/>
</resultMap>
<insert id="insert" parameterType="com.ruoyi.jarvis.domain.ErpGoofishOrderEventLog" useGeneratedKeys="true" keyProperty="id">
insert into erp_goofish_order_event_log
(order_id, app_key, order_no, event_type, source, message, create_time)
values
(#{orderId}, #{appKey}, #{orderNo}, #{eventType}, #{source}, #{message}, #{createTime})
</insert>
<select id="selectByOrderId" resultMap="ErpGoofishOrderEventLogResult">
select id, order_id, app_key, order_no, event_type, source, message, create_time
from erp_goofish_order_event_log
where order_id = #{orderId}
order by id desc
</select>
<select id="selectLogList" parameterType="com.ruoyi.jarvis.domain.ErpGoofishOrderEventLogQuery" resultMap="ErpGoofishOrderEventLogResult">
select id, order_id, app_key, order_no, event_type, source, message, create_time
from erp_goofish_order_event_log
<where>
<if test="orderId != null">and order_id = #{orderId}</if>
<if test="appKey != null and appKey != ''">and app_key = #{appKey}</if>
<if test="orderNo != null and orderNo != ''">and order_no like concat('%', #{orderNo}, '%')</if>
<if test="eventType != null and eventType != ''">and event_type = #{eventType}</if>
<if test="source != null and source != ''">and source like concat('%', #{source}, '%')</if>
<if test="messageKeyword != null and messageKeyword != ''">and message like concat('%', #{messageKeyword}, '%')</if>
<if test="params != null and params.beginTime != null and params.beginTime != ''">
and create_time &gt;= concat(#{params.beginTime}, ' 00:00:00')
</if>
<if test="params != null and params.endTime != null and params.endTime != ''">
and create_time &lt;= concat(#{params.endTime}, ' 23:59:59')
</if>
</where>
order by id desc
</select>
</mapper>

View File

@@ -0,0 +1,183 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.jarvis.mapper.ErpGoofishOrderMapper">
<resultMap id="ErpGoofishOrderResult" type="com.ruoyi.jarvis.domain.ErpGoofishOrder">
<id property="id" column="id"/>
<result property="appKey" column="app_key"/>
<result property="sellerId" column="seller_id"/>
<result property="userName" column="user_name"/>
<result property="orderNo" column="order_no"/>
<result property="orderType" column="order_type"/>
<result property="orderStatus" column="order_status"/>
<result property="refundStatus" column="refund_status"/>
<result property="modifyTime" column="modify_time"/>
<result property="productId" column="product_id"/>
<result property="itemId" column="item_id"/>
<result property="goodsTitle" column="goods_title"/>
<result property="goodsImageUrl" column="goods_image_url"/>
<result property="buyerNick" column="buyer_nick"/>
<result property="payAmount" column="pay_amount"/>
<result property="detailWaybillNo" column="detail_waybill_no"/>
<result property="detailExpressCode" column="detail_express_code"/>
<result property="detailExpressName" column="detail_express_name"/>
<result property="receiverName" column="receiver_name"/>
<result property="receiverMobile" column="receiver_mobile"/>
<result property="receiverAddress" column="receiver_address"/>
<result property="receiverRegion" column="receiver_region"/>
<result property="recvProvName" column="recv_prov_name"/>
<result property="recvCityName" column="recv_city_name"/>
<result property="recvAreaName" column="recv_area_name"/>
<result property="recvTownName" column="recv_town_name"/>
<result property="detailJson" column="detail_json"/>
<result property="lastNotifyJson" column="last_notify_json"/>
<result property="jdOrderId" column="jd_order_id"/>
<result property="localWaybillNo" column="local_waybill_no"/>
<result property="shipStatus" column="ship_status"/>
<result property="shipError" column="ship_error"/>
<result property="shipTime" column="ship_time"/>
<result property="shipExpressCode" column="ship_express_code"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
<result property="jdThirdPartyOrderNo" column="jd_third_party_order_no"/>
<result property="jdRemark" column="jd_remark"/>
<result property="jdAddress" column="jd_address"/>
</resultMap>
<sql id="selectJoinVo">
select e.id, e.app_key, e.seller_id, e.user_name, e.order_no, e.order_type, e.order_status, e.refund_status,
e.modify_time, e.product_id, e.item_id, e.goods_title, e.goods_image_url, e.buyer_nick, e.pay_amount, e.detail_waybill_no,
e.detail_express_code, e.detail_express_name, e.receiver_name, e.receiver_mobile, e.receiver_address,
e.receiver_region, e.recv_prov_name, e.recv_city_name, e.recv_area_name, e.recv_town_name,
e.detail_json, e.last_notify_json, e.jd_order_id, e.local_waybill_no,
e.ship_status, e.ship_error, e.ship_time, e.ship_express_code, e.create_time, e.update_time,
o.third_party_order_no as jd_third_party_order_no, o.remark as jd_remark, o.address as jd_address
from erp_goofish_order e
left join jd_order o on e.jd_order_id = o.id
</sql>
<select id="selectById" resultMap="ErpGoofishOrderResult">
<include refid="selectJoinVo"/> where e.id = #{id}
</select>
<select id="selectByAppKeyAndOrderNo" resultMap="ErpGoofishOrderResult">
<include refid="selectJoinVo"/> where e.app_key = #{appKey} and e.order_no = #{orderNo} limit 1
</select>
<select id="selectList" parameterType="com.ruoyi.jarvis.domain.ErpGoofishOrder" resultMap="ErpGoofishOrderResult">
<include refid="selectJoinVo"/>
<where>
<if test="appKey != null and appKey != ''">and e.app_key = #{appKey}</if>
<if test="userName != null and userName != ''">and e.user_name like concat('%', #{userName}, '%')</if>
<if test="orderNo != null and orderNo != ''">and e.order_no like concat('%', #{orderNo}, '%')</if>
<if test="orderStatus != null">and e.order_status = #{orderStatus}</if>
<if test="refundStatus != null">and e.refund_status = #{refundStatus}</if>
<if test="orderType != null">and e.order_type = #{orderType}</if>
<if test="shipStatus != null">and e.ship_status = #{shipStatus}</if>
<if test="jdOrderId != null">and e.jd_order_id = #{jdOrderId}</if>
<if test="buyerNick != null and buyerNick != ''">and e.buyer_nick like concat('%', #{buyerNick}, '%')</if>
<if test="goodsTitle != null and goodsTitle != ''">and e.goods_title like concat('%', #{goodsTitle}, '%')</if>
<if test="receiverMobile != null and receiverMobile != ''">and e.receiver_mobile like concat('%', #{receiverMobile}, '%')</if>
<if test="itemId != null">and e.item_id = #{itemId}</if>
<if test="productId != null">and e.product_id = #{productId}</if>
<if test="waybillKeyword != null and waybillKeyword != ''">
and (e.detail_waybill_no like concat('%', #{waybillKeyword}, '%')
or e.local_waybill_no like concat('%', #{waybillKeyword}, '%'))
</if>
<if test="jdRemark != null and jdRemark != ''">and o.remark like concat('%', #{jdRemark}, '%')</if>
<if test="jdThirdPartyOrderNo != null and jdThirdPartyOrderNo != ''">and o.third_party_order_no like concat('%', #{jdThirdPartyOrderNo}, '%')</if>
<if test="modifyTimeBegin != null">and e.modify_time &gt;= #{modifyTimeBegin}</if>
<if test="modifyTimeEnd != null">and e.modify_time &lt;= #{modifyTimeEnd}</if>
<if test="jdLinkFilter != null">
<choose>
<when test="jdLinkFilter == 1">and e.jd_order_id is not null</when>
<when test="jdLinkFilter == 0">and e.jd_order_id is null</when>
</choose>
</if>
</where>
order by e.modify_time desc, e.id desc
</select>
<select id="selectPendingShip" resultMap="ErpGoofishOrderResult">
<include refid="selectJoinVo"/>
where (e.ship_status is null or e.ship_status != 1)
and (e.refund_status is null or e.refund_status = 0)
<if test="statuses != null and statuses.size() &gt; 0">
and e.order_status in
<foreach collection="statuses" item="st" open="(" separator="," close=")">
#{st}
</foreach>
</if>
order by e.update_time asc
limit #{limit}
</select>
<select id="selectByGoofishOrderNo" resultMap="ErpGoofishOrderResult">
<include refid="selectJoinVo"/>
where e.order_no = #{orderNo}
</select>
<insert id="insert" parameterType="com.ruoyi.jarvis.domain.ErpGoofishOrder" useGeneratedKeys="true" keyProperty="id">
insert into erp_goofish_order
(app_key, seller_id, user_name, order_no, order_type, order_status, refund_status, modify_time, product_id, item_id,
goods_title, goods_image_url, buyer_nick, pay_amount, detail_waybill_no, detail_express_code, detail_express_name,
receiver_name, receiver_mobile, receiver_address, receiver_region,
recv_prov_name, recv_city_name, recv_area_name, recv_town_name,
detail_json, last_notify_json, jd_order_id, local_waybill_no, ship_status, ship_error, ship_time, ship_express_code,
create_time, update_time)
values
(#{appKey}, #{sellerId}, #{userName}, #{orderNo}, #{orderType}, #{orderStatus}, #{refundStatus}, #{modifyTime}, #{productId}, #{itemId},
#{goodsTitle}, #{goodsImageUrl}, #{buyerNick}, #{payAmount}, #{detailWaybillNo}, #{detailExpressCode}, #{detailExpressName},
#{receiverName}, #{receiverMobile}, #{receiverAddress}, #{receiverRegion},
#{recvProvName}, #{recvCityName}, #{recvAreaName}, #{recvTownName},
#{detailJson}, #{lastNotifyJson}, #{jdOrderId}, #{localWaybillNo}, #{shipStatus}, #{shipError}, #{shipTime}, #{shipExpressCode},
#{createTime}, #{updateTime})
</insert>
<update id="update" parameterType="com.ruoyi.jarvis.domain.ErpGoofishOrder">
update erp_goofish_order
<trim prefix="SET" suffixOverrides=",">
<if test="sellerId != null">seller_id = #{sellerId},</if>
<if test="userName != null">user_name = #{userName},</if>
<if test="orderType != null">order_type = #{orderType},</if>
<if test="orderStatus != null">order_status = #{orderStatus},</if>
<if test="refundStatus != null">refund_status = #{refundStatus},</if>
<if test="modifyTime != null">modify_time = #{modifyTime},</if>
<if test="productId != null">product_id = #{productId},</if>
<if test="itemId != null">item_id = #{itemId},</if>
<if test="goodsTitle != null">goods_title = #{goodsTitle},</if>
<if test="goodsImageUrl != null">goods_image_url = #{goodsImageUrl},</if>
<if test="buyerNick != null">buyer_nick = #{buyerNick},</if>
<if test="payAmount != null">pay_amount = #{payAmount},</if>
<if test="detailWaybillNo != null">detail_waybill_no = #{detailWaybillNo},</if>
<if test="detailExpressCode != null">detail_express_code = #{detailExpressCode},</if>
<if test="detailExpressName != null">detail_express_name = #{detailExpressName},</if>
<if test="receiverName != null">receiver_name = #{receiverName},</if>
<if test="receiverMobile != null">receiver_mobile = #{receiverMobile},</if>
<if test="receiverAddress != null">receiver_address = #{receiverAddress},</if>
<if test="receiverRegion != null">receiver_region = #{receiverRegion},</if>
<if test="recvProvName != null">recv_prov_name = #{recvProvName},</if>
<if test="recvCityName != null">recv_city_name = #{recvCityName},</if>
<if test="recvAreaName != null">recv_area_name = #{recvAreaName},</if>
<if test="recvTownName != null">recv_town_name = #{recvTownName},</if>
<if test="detailJson != null">detail_json = #{detailJson},</if>
<if test="lastNotifyJson != null">last_notify_json = #{lastNotifyJson},</if>
<if test="jdOrderId != null">jd_order_id = #{jdOrderId},</if>
<if test="localWaybillNo != null">local_waybill_no = #{localWaybillNo},</if>
<if test="shipStatus != null">ship_status = #{shipStatus},</if>
<if test="shipError != null">ship_error = #{shipError},</if>
<if test="shipTime != null">ship_time = #{shipTime},</if>
<if test="shipExpressCode != null">ship_express_code = #{shipExpressCode},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</trim>
where id = #{id}
</update>
<update id="resetShipForRetry">
update erp_goofish_order
set ship_status = 0,
ship_error = null,
update_time = now()
where id = #{id}
</update>
</mapper>

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.jarvis.mapper.ErpOpenConfigMapper">
<resultMap id="ErpOpenConfigResult" type="com.ruoyi.jarvis.domain.ErpOpenConfig">
<id property="id" column="id"/>
<result property="appKey" column="app_key"/>
<result property="appSecret" column="app_secret"/>
<result property="xyUserName" column="xy_user_name"/>
<result property="expressCode" column="express_code"/>
<result property="expressName" column="express_name"/>
<result property="status" column="status"/>
<result property="orderNum" column="order_num"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
<result property="remark" column="remark"/>
</resultMap>
<sql id="selectVo">
select id, app_key, app_secret, xy_user_name, express_code, express_name, status, order_num,
create_by, create_time, update_by, update_time, remark
from erp_open_config
</sql>
<select id="selectById" resultMap="ErpOpenConfigResult">
<include refid="selectVo"/> where id = #{id}
</select>
<select id="selectByAppKey" resultMap="ErpOpenConfigResult">
<include refid="selectVo"/> where app_key = #{appKey} limit 1
</select>
<select id="selectList" parameterType="com.ruoyi.jarvis.domain.ErpOpenConfig" resultMap="ErpOpenConfigResult">
<include refid="selectVo"/>
<where>
<if test="appKey != null and appKey != ''">and app_key = #{appKey}</if>
<if test="status != null and status != ''">and status = #{status}</if>
<if test="xyUserName != null and xyUserName != ''">and xy_user_name like concat('%', #{xyUserName}, '%')</if>
</where>
order by order_num asc, id asc
</select>
<select id="selectEnabledOrderBySort" resultMap="ErpOpenConfigResult">
<include refid="selectVo"/>
where status = '0'
order by order_num asc, id asc
</select>
<insert id="insert" parameterType="com.ruoyi.jarvis.domain.ErpOpenConfig" useGeneratedKeys="true" keyProperty="id">
insert into erp_open_config
(app_key, app_secret, xy_user_name, express_code, express_name, status, order_num, create_by, create_time, remark)
values
(#{appKey}, #{appSecret}, #{xyUserName}, #{expressCode}, #{expressName}, #{status}, #{orderNum}, #{createBy}, #{createTime}, #{remark})
</insert>
<update id="update" parameterType="com.ruoyi.jarvis.domain.ErpOpenConfig">
update erp_open_config
<trim prefix="SET" suffixOverrides=",">
<if test="appKey != null">app_key = #{appKey},</if>
<if test="appSecret != null">app_secret = #{appSecret},</if>
<if test="xyUserName != null">xy_user_name = #{xyUserName},</if>
<if test="expressCode != null">express_code = #{expressCode},</if>
<if test="expressName != null">express_name = #{expressName},</if>
<if test="status != null">status = #{status},</if>
<if test="orderNum != null">order_num = #{orderNum},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteById">
delete from erp_open_config where id = #{id}
</delete>
</mapper>

View File

@@ -67,6 +67,7 @@
</if> </if>
<if test="distributionMark != null and distributionMark != ''"> and distribution_mark = #{distributionMark}</if> <if test="distributionMark != null and distributionMark != ''"> and distribution_mark = #{distributionMark}</if>
<if test="modelNumber != null and modelNumber != ''"> and model_number like concat('%', #{modelNumber}, '%')</if> <if test="modelNumber != null and modelNumber != ''"> and model_number like concat('%', #{modelNumber}, '%')</if>
<if test="modelNumberExclude != null and modelNumberExclude != ''"> and (model_number is null or model_number not like concat('%', #{modelNumberExclude}, '%'))</if>
<if test="link != null and link != ''"> and link like concat('%', #{link}, '%')</if> <if test="link != null and link != ''"> and link like concat('%', #{link}, '%')</if>
<if test="paymentAmount != null"> and payment_amount = #{paymentAmount}</if> <if test="paymentAmount != null"> and payment_amount = #{paymentAmount}</if>
<if test="rebateAmount != null"> and rebate_amount = #{rebateAmount}</if> <if test="rebateAmount != null"> and rebate_amount = #{rebateAmount}</if>
@@ -85,6 +86,13 @@
<if test="params.hasRebateRemark != null and params.hasRebateRemark == true"> <if test="params.hasRebateRemark != null and params.hasRebateRemark == true">
and rebate_remark_json is not null and char_length(trim(rebate_remark_json)) &gt; 2 and rebate_remark_json is not null and char_length(trim(rebate_remark_json)) &gt; 2
</if> </if>
<if test="params.rebateWithoutUploadLink != null and params.rebateWithoutUploadLink == true">
and (
rebate_remark_json is null
or char_length(trim(rebate_remark_json)) &lt;= 2
or rebate_remark_json not like '%"uploadRecordId"%'
)
</if>
<if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 --> <if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
and date(order_time) &gt;= #{params.beginTime} and date(order_time) &gt;= #{params.beginTime}
</if> </if>
@@ -108,6 +116,7 @@
</if> </if>
<if test="distributionMark != null and distributionMark != ''"> and distribution_mark = #{distributionMark}</if> <if test="distributionMark != null and distributionMark != ''"> and distribution_mark = #{distributionMark}</if>
<if test="modelNumber != null and modelNumber != ''"> and model_number like concat('%', #{modelNumber}, '%')</if> <if test="modelNumber != null and modelNumber != ''"> and model_number like concat('%', #{modelNumber}, '%')</if>
<if test="modelNumberExclude != null and modelNumberExclude != ''"> and (model_number is null or model_number not like concat('%', #{modelNumberExclude}, '%'))</if>
<if test="link != null and link != ''"> and link like concat('%', #{link}, '%')</if> <if test="link != null and link != ''"> and link like concat('%', #{link}, '%')</if>
<if test="paymentAmount != null"> and payment_amount = #{paymentAmount}</if> <if test="paymentAmount != null"> and payment_amount = #{paymentAmount}</if>
<if test="rebateAmount != null"> and rebate_amount = #{rebateAmount}</if> <if test="rebateAmount != null"> and rebate_amount = #{rebateAmount}</if>
@@ -126,6 +135,13 @@
<if test="params.hasRebateRemark != null and params.hasRebateRemark == true"> <if test="params.hasRebateRemark != null and params.hasRebateRemark == true">
and rebate_remark_json is not null and char_length(trim(rebate_remark_json)) &gt; 2 and rebate_remark_json is not null and char_length(trim(rebate_remark_json)) &gt; 2
</if> </if>
<if test="params.rebateWithoutUploadLink != null and params.rebateWithoutUploadLink == true">
and (
rebate_remark_json is null
or char_length(trim(rebate_remark_json)) &lt;= 2
or rebate_remark_json not like '%"uploadRecordId"%'
)
</if>
<if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 --> <if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
and date(order_time) &gt;= #{params.beginTime} and date(order_time) &gt;= #{params.beginTime}
</if> </if>
@@ -268,7 +284,7 @@
<select id="selectJDOrderListByDistributionMarkFOrPDD" resultMap="JDOrderResult"> <select id="selectJDOrderListByDistributionMarkFOrPDD" resultMap="JDOrderResult">
<include refid="selectJDOrderBase"/> <include refid="selectJDOrderBase"/>
<where> <where>
(distribution_mark = 'F' OR distribution_mark = 'PDD' OR distribution_mark = 'H' OR distribution_mark = 'W' OR distribution_mark = 'PDD-W') (distribution_mark = 'F' OR distribution_mark LIKE 'F-%' OR distribution_mark = 'PDD' OR distribution_mark = 'H' OR distribution_mark = 'W' OR distribution_mark = 'PDD-W')
AND logistics_link IS NOT NULL AND logistics_link IS NOT NULL
AND logistics_link != '' AND logistics_link != ''
AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
@@ -276,6 +292,26 @@
ORDER BY create_time DESC ORDER BY create_time DESC
</select> </select>
<resultMap id="QuickRecordModelOptionResult" type="com.ruoyi.jarvis.domain.dto.QuickRecordModelOption">
<result property="modelNumber" column="model_number"/>
<result property="lastPaymentAmount" column="last_payment_amount"/>
<result property="lastRebateAmount" column="last_rebate_amount"/>
</resultMap>
<select id="selectQuickRecordModelOptions" resultMap="QuickRecordModelOptionResult">
select o.model_number as model_number,
o.payment_amount as last_payment_amount,
o.rebate_amount as last_rebate_amount
from jd_order o
inner join (
select trim(model_number) as m, max(id) as mid
from jd_order
where model_number is not null and trim(model_number) != ''
group by trim(model_number)
) t on trim(o.model_number) = t.m and o.id = t.mid
order by o.id desc
</select>
</mapper> </mapper>

View File

@@ -91,10 +91,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<include refid="selectBatchPushRecordVo"/> <include refid="selectBatchPushRecordVo"/>
WHERE file_id = #{fileId} WHERE file_id = #{fileId}
AND sheet_id = #{sheetId} AND sheet_id = #{sheetId}
AND status IN ('SUCCESS', 'PARTIAL') AND status IN ('SUCCESS', 'PARTIAL', 'PARTIAL_SUCCESS')
ORDER BY end_time DESC ORDER BY end_time DESC
LIMIT 1 LIMIT 1
</select> </select>
<select id="selectRunningRecordsBefore" resultMap="BatchPushRecordResult">
<include refid="selectBatchPushRecordVo"/>
WHERE status = 'RUNNING'
<if test="fileId != null and fileId != ''">AND file_id = #{fileId}</if>
AND start_time &lt; #{beforeTime}
ORDER BY start_time ASC
</select>
</mapper> </mapper>

Some files were not shown because too many files have changed in this diff Show More