diff --git a/doc/腾讯文档操作日志表.sql b/doc/腾讯文档操作日志表.sql new file mode 100644 index 0000000..d11700a --- /dev/null +++ b/doc/腾讯文档操作日志表.sql @@ -0,0 +1,20 @@ +-- 腾讯文档操作日志表 +CREATE TABLE `tencent_doc_operation_log` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `file_id` varchar(100) DEFAULT NULL COMMENT '文档ID', + `sheet_id` varchar(100) DEFAULT NULL COMMENT '工作表ID', + `operation_type` varchar(50) DEFAULT NULL COMMENT '操作类型:WRITE_SINGLE(单个写入)、BATCH_SYNC(批量同步)', + `order_no` varchar(100) DEFAULT NULL COMMENT '订单单号', + `target_row` int(11) DEFAULT NULL COMMENT '目标行号', + `logistics_link` varchar(500) DEFAULT NULL COMMENT '写入的物流链接', + `operation_status` varchar(20) DEFAULT NULL COMMENT '操作状态:SUCCESS(成功)、FAILED(失败)、SKIPPED(跳过)', + `error_message` text COMMENT '错误信息', + `operator` varchar(100) DEFAULT NULL COMMENT '操作人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + KEY `idx_order_no` (`order_no`), + KEY `idx_create_time` (`create_time`), + KEY `idx_file_sheet` (`file_id`, `sheet_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯文档操作日志表'; + diff --git a/doc/腾讯文档物流链接填充-严格模式说明.md b/doc/腾讯文档物流链接填充-严格模式说明.md new file mode 100644 index 0000000..f8ca478 --- /dev/null +++ b/doc/腾讯文档物流链接填充-严格模式说明.md @@ -0,0 +1,130 @@ +# 腾讯文档物流链接填充 - 严格模式 + +## 🔒 核心安全机制 + +### 1. **分布式锁** +- 使用Redis分布式锁,防止并发写入 +- 锁的粒度:`文档ID:工作表ID:订单单号` +- 锁超时时间:30秒 +- 确保同一订单同一时刻只能有一个请求处理 + +### 2. **操作日志记录** +- 所有操作都会记录到数据库表 `tencent_doc_operation_log` +- 记录内容包括: + - 文档ID、工作表ID + - 操作类型(WRITE_SINGLE / BATCH_SYNC) + - 订单单号、目标行号 + - 物流链接 + - 操作状态(SUCCESS / FAILED / SKIPPED) + - 错误信息 + - 操作人、操作时间 + +### 3. **写入前验证** +在写入之前,会进行以下验证: +1. **再次读取目标行** - 防止行数据在查找和写入之间发生变化 +2. **验证单号匹配** - 确保单号仍然在预期的行 +3. **验证物流列为空** - 如果已有物流链接,则拒绝写入,防止覆盖 + +### 4. **录单不再自动触发** +- **旧行为**:录单时如果分销标识是 `H-TF`,自动写入腾讯文档 +- **新行为**:录单时不再自动写入,必须通过订单列表手动触发 +- **原因**:防止并发写入和数据覆盖,需要人工确认 + +## 📋 操作流程 + +### 单个订单填充物流链接 +1. 在订单列表找到目标订单 +2. 点击"推送物流"按钮(或类似按钮) +3. 系统会: + - 获取分布式锁 + - 读取表头识别列位置 + - 查找订单单号所在行 + - 验证单号和物流列 + - 写入物流链接 + - 记录操作日志 + - 释放锁 + +### 批量同步物流链接 +1. 点击"批量同步"按钮 +2. 系统会自动: + - 读取表格数据 + - 根据单号查询订单系统 + - 逐个写入(每个都有锁保护) + - 记录所有操作日志 + +## 🛡️ 安全保障 + +### 防止数据覆盖 +- ✅ 分布式锁防止并发写入 +- ✅ 写入前验证单号匹配 +- ✅ 写入前检查物流列是否为空 +- ✅ 如果物流列已有值,拒绝写入并提示 + +### 操作可追溯 +- ✅ 所有操作都记录到数据库 +- ✅ 记录操作人、操作时间 +- ✅ 记录成功/失败/跳过状态 +- ✅ 记录错误原因 + +## 📊 数据库表结构 + +```sql +CREATE TABLE `tencent_doc_operation_log` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `file_id` varchar(100) DEFAULT NULL COMMENT '文档ID', + `sheet_id` varchar(100) DEFAULT NULL COMMENT '工作表ID', + `operation_type` varchar(50) DEFAULT NULL COMMENT '操作类型', + `order_no` varchar(100) DEFAULT NULL COMMENT '订单单号', + `target_row` int(11) DEFAULT NULL COMMENT '目标行号', + `logistics_link` varchar(500) DEFAULT NULL COMMENT '写入的物流链接', + `operation_status` varchar(20) DEFAULT NULL COMMENT '操作状态', + `error_message` text COMMENT '错误信息', + `operator` varchar(100) DEFAULT NULL COMMENT '操作人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + KEY `idx_order_no` (`order_no`), + KEY `idx_create_time` (`create_time`), + KEY `idx_file_sheet` (`file_id`, `sheet_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯文档操作日志表'; +``` + +## ⚠️ 注意事项 + +1. **必须先执行SQL** - 请先执行 `doc/腾讯文档操作日志表.sql` 创建日志表 +2. **Redis必须可用** - 分布式锁依赖Redis +3. **手动触发** - 录单后需要手动点击按钮推送到腾讯文档 +4. **物流列非空则跳过** - 如果物流列已有值,会拒绝写入并提示 + +## 🔍 日志查询示例 + +### 查询某个订单的操作历史 +```sql +SELECT * FROM tencent_doc_operation_log +WHERE order_no = 'JY2025110329041' +ORDER BY create_time DESC; +``` + +### 查询失败的操作 +```sql +SELECT * FROM tencent_doc_operation_log +WHERE operation_status = 'FAILED' +ORDER BY create_time DESC +LIMIT 100; +``` + +### 查询被跳过的操作(物流已存在) +```sql +SELECT * FROM tencent_doc_operation_log +WHERE operation_status = 'SKIPPED' +AND error_message LIKE '%物流链接列已有值%' +ORDER BY create_time DESC; +``` + +## 📞 技术支持 + +如遇到问题,请检查: +1. 操作日志表 `tencent_doc_operation_log` +2. 应用日志中的 `TencentDocController` 相关日志 +3. Redis是否正常运行 + diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java index 1a46f5f..c977f4d 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java @@ -45,9 +45,15 @@ public class TencentDocController extends BaseController { @Autowired private com.ruoyi.jarvis.config.TencentDocConfig tencentDocConfig; + @Autowired + private com.ruoyi.jarvis.mapper.TencentDocOperationLogMapper operationLogMapper; + /** Redis key前缀,用于存储上次处理的最大行数 */ private static final String LAST_PROCESSED_ROW_KEY_PREFIX = "tendoc:last_row:"; + /** Redis key前缀,用于分布式锁 */ + private static final String TENCENT_DOC_LOCK_KEY = "tendoc:write:lock:"; + /** * 测试回调接口是否可访问(用于调试) */ @@ -452,9 +458,14 @@ public class TencentDocController extends BaseController { */ @PostMapping("/fillSingleLogistics") public AjaxResult fillSingleLogistics(@RequestBody Map params) { + String thirdPartyOrderNo = null; + String fileId = null; + String sheetId = null; + String lockKey = null; + try { // 1. 获取参数 - String thirdPartyOrderNo = (String) params.get("thirdPartyOrderNo"); + thirdPartyOrderNo = (String) params.get("thirdPartyOrderNo"); String logisticsLink = (String) params.get("logisticsLink"); if (thirdPartyOrderNo == null || thirdPartyOrderNo.isEmpty()) { @@ -469,13 +480,15 @@ public class TencentDocController extends BaseController { try { accessToken = tencentDocTokenService.getValidAccessToken(); } catch (Exception e) { + logOperation(null, null, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, + "FAILED", "访问令牌无效: " + e.getMessage()); return AjaxResult.error("访问令牌无效,请先完成授权"); } // 3. 从配置中读取文档信息 final String CONFIG_KEY_PREFIX = "tencent:doc:auto:config:"; - String fileId = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "fileId"); - String sheetId = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "sheetId"); + fileId = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "fileId"); + sheetId = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "sheetId"); Integer headerRow = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "headerRow"); Integer configStartRow = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "startRow"); @@ -493,21 +506,40 @@ public class TencentDocController extends BaseController { } if (fileId == null || fileId.isEmpty() || sheetId == null || sheetId.isEmpty()) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, + "FAILED", "文档配置不完整"); return AjaxResult.error("文档配置不完整,请先完成配置"); } - // 4. 读取表头(从配置的 headerRow 读取,与 startRow 独立) + // 4. 获取分布式锁(针对该订单单号+文档+工作表) + lockKey = TENCENT_DOC_LOCK_KEY + fileId + ":" + sheetId + ":" + thirdPartyOrderNo; + boolean lockAcquired = redisCache.setCacheObject(lockKey, "locked", 30, TimeUnit.SECONDS); + + if (!lockAcquired) { + log.warn("获取锁失败,该订单正在被其他请求处理:{}", thirdPartyOrderNo); + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, + "FAILED", "获取分布式锁失败,该订单正在处理中"); + return AjaxResult.error("该订单正在处理中,请稍后再试"); + } + + log.info("✓ 获取分布式锁成功 - 单号: {}, lockKey: {}", thirdPartyOrderNo, lockKey); + + // 5. 读取表头(从配置的 headerRow 读取,与 startRow 独立) String headerRange = String.format("A%d:Z%d", headerRow, headerRow); JSONObject headerData = tencentDocService.readSheetData(accessToken, fileId, sheetId, headerRange); if (headerData == null || !headerData.containsKey("gridData")) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, + "FAILED", "无法读取表头数据"); return AjaxResult.error("无法读取表头数据"); } - // 5. 解析表头,找到"单号"和"物流单号"列 + // 6. 解析表头,找到"单号"和"物流单号"列 JSONObject gridData = headerData.getJSONObject("gridData"); JSONArray rows = gridData.getJSONArray("rows"); if (rows == null || rows.isEmpty()) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, + "FAILED", "表头数据为空"); return AjaxResult.error("表头数据为空"); } @@ -532,16 +564,20 @@ public class TencentDocController extends BaseController { } if (orderNoColumn == -1 || logisticsColumn == -1) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, + "FAILED", "未找到'单号'或'物流'列"); return AjaxResult.error("未找到'单号'或'物流'列,请检查表头配置"); } log.info("表头解析完成 - 单号列: {}, 物流列: {}", orderNoColumn, logisticsColumn); - // 6. 读取数据区域,查找指定单号 + // 7. 读取数据区域,查找指定单号 String dataRange = String.format("A%d:Z%d", configStartRow, configStartRow + 999); JSONObject data = tencentDocService.readSheetData(accessToken, fileId, sheetId, dataRange); if (data == null || !data.containsKey("gridData")) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, + "FAILED", "无法读取数据区域"); return AjaxResult.error("无法读取数据区域"); } @@ -549,10 +585,12 @@ public class TencentDocController extends BaseController { JSONArray dataRows = dataGridData.getJSONArray("rows"); if (dataRows == null || dataRows.isEmpty()) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, + "FAILED", "数据区域为空"); return AjaxResult.error("数据区域为空"); } - // 7. 查找匹配的单号 + // 8. 查找匹配的单号 int targetRow = -1; for (int i = 0; i < dataRows.size(); i++) { JSONObject row = dataRows.getJSONObject(i); @@ -571,23 +609,85 @@ public class TencentDocController extends BaseController { } if (targetRow == -1) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, logisticsLink, + "FAILED", "未找到单号"); return AjaxResult.error("未找到单号: " + thirdPartyOrderNo); } log.info("找到单号 {} 在第 {} 行", thirdPartyOrderNo, targetRow); - // 8. 获取用户信息(获取 openId) + // 9. 再次读取该行数据,验证单号和物流链接列是否为空(防止并发覆盖) + String verifyRange = String.format("A%d:Z%d", targetRow, targetRow); + JSONObject verifyData = tencentDocService.readSheetData(accessToken, fileId, sheetId, verifyRange); + + if (verifyData == null || !verifyData.containsKey("gridData")) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, targetRow, logisticsLink, + "FAILED", "验证读取失败"); + return AjaxResult.error("验证读取失败"); + } + + JSONObject verifyGridData = verifyData.getJSONObject("gridData"); + JSONArray verifyRows = verifyGridData.getJSONArray("rows"); + + if (verifyRows == null || verifyRows.isEmpty()) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, targetRow, logisticsLink, + "FAILED", "验证行数据为空"); + return AjaxResult.error("验证行数据为空"); + } + + JSONObject verifyRowData = verifyRows.getJSONObject(0); + JSONArray verifyCells = verifyRowData.getJSONArray("values"); + + // 验证单号是否仍然匹配 + if (verifyCells == null || verifyCells.size() <= orderNoColumn) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, targetRow, logisticsLink, + "SKIPPED", "验证时单号列为空,行已变化"); + return AjaxResult.error("验证失败:行数据已变化,单号列为空"); + } + + JSONObject verifyOrderNoCell = verifyCells.getJSONObject(orderNoColumn); + String verifyOrderNo = null; + if (verifyOrderNoCell.containsKey("cellValue")) { + verifyOrderNo = verifyOrderNoCell.getJSONObject("cellValue").getString("text"); + } + + if (!thirdPartyOrderNo.equals(verifyOrderNo)) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, targetRow, logisticsLink, + "SKIPPED", String.format("验证失败:单号不匹配,期望 %s,实际 %s", thirdPartyOrderNo, verifyOrderNo)); + return AjaxResult.error("验证失败:单号不匹配,行数据已变化"); + } + + // 验证物流链接列是否为空 + if (verifyCells.size() > logisticsColumn) { + JSONObject logisticsCell = verifyCells.getJSONObject(logisticsColumn); + if (logisticsCell.containsKey("cellValue")) { + String existingLogistics = logisticsCell.getJSONObject("cellValue").getString("text"); + if (existingLogistics != null && !existingLogistics.trim().isEmpty()) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, targetRow, logisticsLink, + "SKIPPED", String.format("物流链接列已有值:%s", existingLogistics)); + return AjaxResult.error(String.format("该订单物流链接已存在:%s", existingLogistics)); + } + } + } + + log.info("✓ 验证通过 - 单号匹配且物流列为空,可以写入"); + + // 10. 获取用户信息(获取 openId) JSONObject userInfo = com.ruoyi.jarvis.util.TencentDocApiUtil.getUserInfo(accessToken); JSONObject userData = userInfo.getJSONObject("data"); if (userData == null) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, targetRow, logisticsLink, + "FAILED", "无法获取用户数据"); return AjaxResult.error("无法获取用户数据"); } String openId = userData.getString("openID"); if (openId == null || openId.isEmpty()) { + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, targetRow, logisticsLink, + "FAILED", "无法获取Open-Id"); return AjaxResult.error("无法获取Open-Id"); } - // 9. 写入物流链接 + // 11. 写入物流链接 com.ruoyi.jarvis.util.TencentDocApiUtil.writeSheetData( accessToken, tencentDocConfig.getAppId(), @@ -599,6 +699,12 @@ public class TencentDocController extends BaseController { tencentDocConfig.getApiBaseUrl() ); + // 12. 记录成功日志 + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, targetRow, logisticsLink, + "SUCCESS", null); + + log.info("✓ 物流链接写入成功 - 单号: {}, 行: {}, 链接: {}", thirdPartyOrderNo, targetRow, logisticsLink); + JSONObject result = new JSONObject(); result.put("thirdPartyOrderNo", thirdPartyOrderNo); result.put("logisticsLink", logisticsLink); @@ -609,7 +715,42 @@ public class TencentDocController extends BaseController { } catch (Exception e) { log.error("填充物流链接失败", e); + logOperation(fileId, sheetId, "WRITE_SINGLE", thirdPartyOrderNo, null, null, + "FAILED", "异常: " + e.getMessage()); return AjaxResult.error("填充物流链接失败: " + e.getMessage()); + } finally { + // 释放分布式锁 + if (lockKey != null) { + try { + redisCache.deleteObject(lockKey); + log.info("✓ 释放分布式锁 - lockKey: {}", lockKey); + } catch (Exception e) { + log.error("释放分布式锁失败", e); + } + } + } + } + + /** + * 记录操作日志到数据库 + */ + private void logOperation(String fileId, String sheetId, String operationType, + String orderNo, Integer targetRow, String logisticsLink, + String status, String errorMessage) { + try { + com.ruoyi.jarvis.domain.TencentDocOperationLog log = new com.ruoyi.jarvis.domain.TencentDocOperationLog(); + log.setFileId(fileId); + log.setSheetId(sheetId); + log.setOperationType(operationType); + log.setOrderNo(orderNo); + log.setTargetRow(targetRow); + log.setLogisticsLink(logisticsLink); + log.setOperationStatus(status); + log.setErrorMessage(errorMessage); + log.setOperator(getUsername()); // 从BaseController继承的方法 + operationLogMapper.insertLog(log); + } catch (Exception e) { + TencentDocController.log.error("记录操作日志失败", e); } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/TencentDocOperationLog.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/TencentDocOperationLog.java new file mode 100644 index 0000000..8e6f787 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/TencentDocOperationLog.java @@ -0,0 +1,47 @@ +package com.ruoyi.jarvis.domain; + +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 腾讯文档操作日志对象 tencent_doc_operation_log + * + * @author system + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class TencentDocOperationLog extends BaseEntity { + private static final long serialVersionUID = 1L; + + /** 主键ID */ + private Long id; + + /** 文档ID */ + private String fileId; + + /** 工作表ID */ + private String sheetId; + + /** 操作类型 */ + private String operationType; + + /** 订单单号 */ + private String orderNo; + + /** 目标行号 */ + private Integer targetRow; + + /** 写入的物流链接 */ + private String logisticsLink; + + /** 操作状态 */ + private String operationStatus; + + /** 错误信息 */ + private String errorMessage; + + /** 操作人 */ + private String operator; +} + diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/TencentDocOperationLogMapper.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/TencentDocOperationLogMapper.java new file mode 100644 index 0000000..a7c56b5 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/TencentDocOperationLogMapper.java @@ -0,0 +1,21 @@ +package com.ruoyi.jarvis.mapper; + +import com.ruoyi.jarvis.domain.TencentDocOperationLog; +import org.apache.ibatis.annotations.Mapper; + +/** + * 腾讯文档操作日志Mapper接口 + * + * @author system + */ +@Mapper +public interface TencentDocOperationLogMapper { + /** + * 插入操作日志 + * + * @param log 操作日志 + * @return 结果 + */ + int insertLog(TencentDocOperationLog log); +} + diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java index 17d0ffc..ddb1e6b 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java @@ -1233,10 +1233,11 @@ private String handleTF(String input) { jdOrderService.insertJDOrder(order); } - // 如果分销标识是 H-TF,自动写入腾讯文档 - if ("H-TF".equals(order.getDistributionMark())) { - asyncWriteToTencentDoc(order); - } + // 注意:H-TF订单不再自动写入腾讯文档,需通过订单列表手动触发 + // 原因:防止并发写入和数据覆盖,需要人工确认 + // if ("H-TF".equals(order.getDistributionMark())) { + // asyncWriteToTencentDoc(order); + // } // 返回完整的表单格式,使用原始输入保留完整物流链接 return formatOrderForm(order, originalInput); diff --git a/ruoyi-system/src/main/resources/mapper/jarvis/TencentDocOperationLogMapper.xml b/ruoyi-system/src/main/resources/mapper/jarvis/TencentDocOperationLogMapper.xml new file mode 100644 index 0000000..a27e678 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/jarvis/TencentDocOperationLogMapper.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + insert into tencent_doc_operation_log + + file_id, + sheet_id, + operation_type, + order_no, + target_row, + logistics_link, + operation_status, + error_message, + operator, + remark, + create_time + + + #{fileId}, + #{sheetId}, + #{operationType}, + #{orderNo}, + #{targetRow}, + #{logisticsLink}, + #{operationStatus}, + #{errorMessage}, + #{operator}, + #{remark}, + now() + + + + +