diff --git a/docs/plan/2026-07-17-todo-center-security-performance-checklist.md b/docs/plan/2026-07-17-todo-center-security-performance-checklist.md index 55b2130cb..569863e73 100644 --- a/docs/plan/2026-07-17-todo-center-security-performance-checklist.md +++ b/docs/plan/2026-07-17-todo-center-security-performance-checklist.md @@ -11,7 +11,7 @@ ## 路由安全 -- 默认仅允许 `invoiceDetail`、`financeDetail` 两个业务路由。 +- 默认允许 `invoiceDetail`、`financeDetail`、`accountUserInfo` 三个业务路由。 - 新业务路由上线前必须追加到 `todo.route-whitelist`,不允许直接信任请求体中的路由名。 - `route_params` 必须是 JSON 对象,只允许 `params`、`query` 两层结构,最终值只能是字符串、数字、布尔值或空值。 - 用户端详情、已读、完成和取消接口继续使用当前登录用户 ID 作为 SQL 条件。 diff --git a/jsowell-admin/src/main/java/com/jsowell/service/OrderService.java b/jsowell-admin/src/main/java/com/jsowell/service/OrderService.java index eb8786135..c5d0f1479 100644 --- a/jsowell-admin/src/main/java/com/jsowell/service/OrderService.java +++ b/jsowell-admin/src/main/java/com/jsowell/service/OrderService.java @@ -121,6 +121,9 @@ public class OrderService { @Autowired private AdapayMemberAccountService adapayMemberAccountService; + @Autowired + private AdapayAccountTodoService adapayAccountTodoService; + @Autowired private ClearingWithdrawInfoService clearingWithdrawInfoService; @@ -1417,7 +1420,7 @@ public class OrderService { } adapayMemberAccount.setAuditState(auditState); // 逻辑删除记录,并删除缓存 - adapayMemberAccountService.updateAdapayMemberAccount(adapayMemberAccount); + adapayAccountTodoService.handleCorpMemberFailed(adapayMemberAccount); if (StringUtils.isNotBlank(adapayMemberAccount.getMerchantId())) { redisCache.deleteObject(CacheConstants.ADAPAY_MEMBER_ACCOUNT + adapayMemberAccount.getMerchantId()); } @@ -1454,7 +1457,7 @@ public class OrderService { adapayMemberAccount.setSettleAccountId(settleAccountId); } adapayMemberAccount.setDelFlag(DelFlagEnum.NORMAL.getValue()); - adapayMemberAccountService.updateAdapayMemberAccount(adapayMemberAccount); + adapayAccountTodoService.handleCorpMemberSucceeded(adapayMemberAccount); if (StringUtils.isNotBlank(adapayMemberAccount.getMerchantId())) { redisCache.deleteObject(CacheConstants.ADAPAY_MEMBER_ACCOUNT + adapayMemberAccount.getMerchantId()); } diff --git a/jsowell-admin/src/main/resources/application.yml b/jsowell-admin/src/main/resources/application.yml index 452962461..64dd72d7f 100644 --- a/jsowell-admin/src/main/resources/application.yml +++ b/jsowell-admin/src/main/resources/application.yml @@ -219,4 +219,4 @@ sms: todo: operator-admin-role-id: 3 retention-days: 180 - route-whitelist: invoiceDetail,financeDetail + route-whitelist: invoiceDetail,financeDetail,accountUserInfo diff --git a/jsowell-admin/src/test/java/com/jsowell/pile/service/AdapayAccountTodoServiceImplTest.java b/jsowell-admin/src/test/java/com/jsowell/pile/service/AdapayAccountTodoServiceImplTest.java new file mode 100644 index 000000000..bcc67165d --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/pile/service/AdapayAccountTodoServiceImplTest.java @@ -0,0 +1,96 @@ +package com.jsowell.pile.service; + +import com.jsowell.common.constant.Constants; +import com.jsowell.pile.constant.AdapayAccountTodoConstants; +import com.jsowell.pile.domain.AdapayMemberAccount; +import com.jsowell.pile.domain.PileMerchantInfo; +import com.jsowell.pile.service.impl.AdapayAccountTodoServiceImpl; +import com.jsowell.system.domain.dto.TodoTaskCreateCommand; +import com.jsowell.system.service.TodoTaskAssigneeService; +import com.jsowell.system.service.TodoTaskService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.lang.reflect.Field; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AdapayAccountTodoServiceImplTest { + @Test + void failed_shouldUpdateAccountAndCreateAdminTodo() { + Dependencies dependencies = new Dependencies(); + AdapayAccountTodoServiceImpl service = dependencies.createService(); + AdapayMemberAccount account = account(); + PileMerchantInfo merchantInfo = new PileMerchantInfo(); + merchantInfo.setDeptId("200"); + when(dependencies.merchantService.selectPileMerchantInfoById(100L)).thenReturn(merchantInfo); + when(dependencies.assigneeService.findActiveOperatorAdminUserIdsByDeptTree(200L)) + .thenReturn(Collections.singletonList(8L)); + account.setAuditDesc("证件资料不清晰"); + + service.handleCorpMemberFailed(account); + + verify(dependencies.accountService).updateAdapayMemberAccount(account); + ArgumentCaptor captor = ArgumentCaptor.forClass(TodoTaskCreateCommand.class); + verify(dependencies.todoTaskService).createTask(captor.capture()); + TodoTaskCreateCommand command = captor.getValue(); + assertEquals(AdapayAccountTodoConstants.TASK_TYPE_CORP_MEMBER_FAILED, command.getTaskType()); + assertEquals(AdapayAccountTodoConstants.BUSINESS_TYPE, command.getBusinessType()); + assertEquals("100", command.getBusinessId()); + assertEquals("accountUserInfo", command.getRouteName()); + assertEquals(8L, command.getAssigneeUserId()); + } + + @Test + void succeeded_shouldUpdateAccountAndCompleteFailureTodosAsSystem() { + Dependencies dependencies = new Dependencies(); + AdapayAccountTodoServiceImpl service = dependencies.createService(); + AdapayMemberAccount account = account(); + + service.handleCorpMemberSucceeded(account); + + verify(dependencies.accountService).updateAdapayMemberAccount(account); + verify(dependencies.todoTaskService).completeByBusinessAsSystem( + AdapayAccountTodoConstants.BUSINESS_TYPE, "100", null, "adapay-callback"); + } + + private static AdapayMemberAccount account() { + AdapayMemberAccount account = new AdapayMemberAccount(); + account.setId(1); + account.setMerchantId("100"); + account.setAdapayMemberId("member-100"); + account.setStatus(Constants.ONE); + return account; + } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = AdapayAccountTodoServiceImpl.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } + + private static class Dependencies { + private final AdapayMemberAccountService accountService = mock(AdapayMemberAccountService.class); + private final PileMerchantInfoService merchantService = mock(PileMerchantInfoService.class); + private final TodoTaskAssigneeService assigneeService = mock(TodoTaskAssigneeService.class); + private final TodoTaskService todoTaskService = mock(TodoTaskService.class); + + private AdapayAccountTodoServiceImpl createService() { + AdapayAccountTodoServiceImpl service = new AdapayAccountTodoServiceImpl(); + setField(service, "adapayMemberAccountService", accountService); + setField(service, "pileMerchantInfoService", merchantService); + setField(service, "todoTaskAssigneeService", assigneeService); + setField(service, "todoTaskService", todoTaskService); + return service; + } + } +} diff --git a/jsowell-admin/src/test/java/com/jsowell/system/service/TodoTaskServiceImplTest.java b/jsowell-admin/src/test/java/com/jsowell/system/service/TodoTaskServiceImplTest.java index 86d51624d..56108b059 100644 --- a/jsowell-admin/src/test/java/com/jsowell/system/service/TodoTaskServiceImplTest.java +++ b/jsowell-admin/src/test/java/com/jsowell/system/service/TodoTaskServiceImplTest.java @@ -140,6 +140,19 @@ class TodoTaskServiceImplTest { verify(mapper, never()).insertTodoTask(any(SysTodoTask.class)); } + @Test + void completeByBusinessAsSystem_shouldAllowNullCompletedBy() { + SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class); + TodoTaskServiceImpl service = newService(mapper); + when(mapper.completeTodoByBusiness("ADAPAY_CORP_MEMBER_ACCOUNT", "100", null, + null, "adapay-callback")).thenReturn(1); + + assertEquals(1, service.completeByBusinessAsSystem("ADAPAY_CORP_MEMBER_ACCOUNT", "100", + null, "adapay-callback")); + verify(mapper).completeTodoByBusiness("ADAPAY_CORP_MEMBER_ACCOUNT", "100", null, + null, "adapay-callback"); + } + private static TodoTaskCreateCommand createCommand() { return TodoTaskCreateCommand.builder() .taskType("INVOICE_REVIEW") diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/constant/AdapayAccountTodoConstants.java b/jsowell-pile/src/main/java/com/jsowell/pile/constant/AdapayAccountTodoConstants.java new file mode 100644 index 000000000..dcf7c4eec --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/constant/AdapayAccountTodoConstants.java @@ -0,0 +1,13 @@ +package com.jsowell.pile.constant; + +/** + * 汇付账户待办常量。 + */ +public final class AdapayAccountTodoConstants { + public static final String TASK_TYPE_CORP_MEMBER_FAILED = "ADAPAY_CORP_MEMBER_FAILED"; + public static final String BUSINESS_TYPE = "ADAPAY_CORP_MEMBER_ACCOUNT"; + public static final String ROUTE_NAME = "accountUserInfo"; + + private AdapayAccountTodoConstants() { + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/AdapayAccountTodoService.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/AdapayAccountTodoService.java new file mode 100644 index 000000000..8a4f93a43 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/AdapayAccountTodoService.java @@ -0,0 +1,12 @@ +package com.jsowell.pile.service; + +import com.jsowell.pile.domain.AdapayMemberAccount; + +/** + * 汇付账户状态与待办联动服务。 + */ +public interface AdapayAccountTodoService { + void handleCorpMemberFailed(AdapayMemberAccount account); + + void handleCorpMemberSucceeded(AdapayMemberAccount account); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/AdapayAccountTodoServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/AdapayAccountTodoServiceImpl.java new file mode 100644 index 000000000..0643fb873 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/AdapayAccountTodoServiceImpl.java @@ -0,0 +1,154 @@ +package com.jsowell.pile.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.jsowell.common.exception.ServiceException; +import com.jsowell.common.util.StringUtils; +import com.jsowell.pile.constant.AdapayAccountTodoConstants; +import com.jsowell.pile.domain.AdapayMemberAccount; +import com.jsowell.pile.domain.PileMerchantInfo; +import com.jsowell.pile.service.AdapayAccountTodoService; +import com.jsowell.pile.service.AdapayMemberAccountService; +import com.jsowell.pile.service.PileMerchantInfoService; +import com.jsowell.system.constant.TodoTaskConstants; +import com.jsowell.system.domain.dto.TodoTaskCreateCommand; +import com.jsowell.system.service.TodoTaskAssigneeService; +import com.jsowell.system.service.TodoTaskService; +import org.apache.commons.collections4.CollectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 汇付账户状态与待办联动服务实现。 + */ +@Service +public class AdapayAccountTodoServiceImpl implements AdapayAccountTodoService { + private static final Logger log = LoggerFactory.getLogger(AdapayAccountTodoServiceImpl.class); + private static final String CALLBACK_OPERATOR = "adapay-callback"; + + @Autowired + private AdapayMemberAccountService adapayMemberAccountService; + + @Autowired + private PileMerchantInfoService pileMerchantInfoService; + + @Autowired + private TodoTaskAssigneeService todoTaskAssigneeService; + + @Autowired + private TodoTaskService todoTaskService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void handleCorpMemberFailed(AdapayMemberAccount account) { + validateAccount(account); + adapayMemberAccountService.updateAdapayMemberAccount(account); + createCorpMemberFailedTodos(account); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void handleCorpMemberSucceeded(AdapayMemberAccount account) { + validateAccount(account); + adapayMemberAccountService.updateAdapayMemberAccount(account); + if (StringUtils.isNotBlank(account.getMerchantId())) { + todoTaskService.completeByBusinessAsSystem(AdapayAccountTodoConstants.BUSINESS_TYPE, + account.getMerchantId(), null, CALLBACK_OPERATOR); + } + } + + private void createCorpMemberFailedTodos(AdapayMemberAccount account) { + if (StringUtils.isBlank(account.getMerchantId())) { + log.warn("汇付企业开户失败记录缺少运营商,无法创建待办,accountId={}, adapayMemberId={}", + account.getId(), account.getAdapayMemberId()); + return; + } + PileMerchantInfo merchantInfo; + try { + merchantInfo = pileMerchantInfoService.selectPileMerchantInfoById( + Long.parseLong(account.getMerchantId())); + } catch (NumberFormatException exception) { + log.warn("汇付企业开户失败记录运营商 ID 非法,无法创建待办,merchantId={}", + account.getMerchantId()); + return; + } + if (merchantInfo == null || StringUtils.isBlank(merchantInfo.getDeptId())) { + log.warn("汇付企业开户失败记录未找到运营商部门,无法创建待办,merchantId={}", + account.getMerchantId()); + return; + } + + Long deptId; + try { + deptId = Long.parseLong(merchantInfo.getDeptId()); + } catch (NumberFormatException exception) { + log.warn("汇付企业开户失败记录运营商部门 ID 非法,无法创建待办,merchantId={}, deptId={}", + account.getMerchantId(), merchantInfo.getDeptId()); + return; + } + List assigneeUserIds = todoTaskAssigneeService.findActiveOperatorAdminUserIdsByDeptTree(deptId); + if (CollectionUtils.isEmpty(assigneeUserIds)) { + log.warn("汇付企业开户失败记录运营商部门下没有正常管理员,无法创建待办,merchantId={}, deptId={}", + account.getMerchantId(), deptId); + return; + } + + String summary = buildSummary(account.getAuditDesc()); + String routeParams = buildRouteParams(account.getMerchantId()); + for (Long assigneeUserId : assigneeUserIds) { + String idempotentKey = AdapayAccountTodoConstants.TASK_TYPE_CORP_MEMBER_FAILED + ":" + + AdapayAccountTodoConstants.BUSINESS_TYPE + ":" + account.getMerchantId() + ":" + + assigneeUserId; + todoTaskService.createTask(TodoTaskCreateCommand.builder() + .taskType(AdapayAccountTodoConstants.TASK_TYPE_CORP_MEMBER_FAILED) + .title("汇付企业开户失败") + .summary(summary) + .content(truncate(account.getAuditDesc(), 20000)) + .businessType(AdapayAccountTodoConstants.BUSINESS_TYPE) + .businessId(account.getMerchantId()) + .routeName(AdapayAccountTodoConstants.ROUTE_NAME) + .routeParams(routeParams) + .assigneeUserId(assigneeUserId) + .assigneeMerchantId(account.getMerchantId()) + .priority(TodoTaskConstants.PRIORITY_IMPORTANT) + .idempotentKey(idempotentKey) + .createBy(CALLBACK_OPERATOR) + .build()); + } + } + + private String buildSummary(String auditDesc) { + String summary = "汇付企业开户失败,请补充或修正开户资料"; + if (StringUtils.isNotBlank(auditDesc)) { + summary += ",原因:" + auditDesc.trim(); + } + return summary.length() <= 500 ? summary : summary.substring(0, 500); + } + + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + + private String buildRouteParams(String merchantId) { + Map params = new HashMap<>(1); + params.put("id", merchantId); + Map routeParams = new HashMap<>(1); + routeParams.put("params", params); + return JSON.toJSONString(routeParams); + } + + private void validateAccount(AdapayMemberAccount account) { + if (account == null || account.getId() == null) { + throw new ServiceException("汇付账户记录不能为空"); + } + } +} diff --git a/jsowell-system/src/main/java/com/jsowell/system/service/TodoTaskService.java b/jsowell-system/src/main/java/com/jsowell/system/service/TodoTaskService.java index 4804c1fe4..0655c8350 100644 --- a/jsowell-system/src/main/java/com/jsowell/system/service/TodoTaskService.java +++ b/jsowell-system/src/main/java/com/jsowell/system/service/TodoTaskService.java @@ -33,6 +33,9 @@ public interface TodoTaskService { int completeByBusiness(String businessType, String businessId, Long assigneeUserId, Long operatorId, String operatorName); + int completeByBusinessAsSystem(String businessType, String businessId, Long assigneeUserId, + String operatorName); + boolean cancelForAssignee(Long todoId, Long assigneeUserId, String operatorName, String reason); boolean cancel(Long todoId, Long operatorId, String operatorName, String reason); diff --git a/jsowell-system/src/main/java/com/jsowell/system/service/impl/TodoTaskServiceImpl.java b/jsowell-system/src/main/java/com/jsowell/system/service/impl/TodoTaskServiceImpl.java index 0f7d4fa11..96c4700ca 100644 --- a/jsowell-system/src/main/java/com/jsowell/system/service/impl/TodoTaskServiceImpl.java +++ b/jsowell-system/src/main/java/com/jsowell/system/service/impl/TodoTaskServiceImpl.java @@ -33,9 +33,9 @@ public class TodoTaskServiceImpl implements TodoTaskService { private static final String SYSTEM_OPERATOR = "system"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final Set DEFAULT_ROUTE_WHITELIST = new HashSet<>( - Arrays.asList("invoiceDetail", "financeDetail")); + Arrays.asList("invoiceDetail", "financeDetail", "accountUserInfo")); - @Value("${todo.route-whitelist:invoiceDetail,financeDetail}") + @Value("${todo.route-whitelist:invoiceDetail,financeDetail,accountUserInfo}") private String routeWhitelist; @Autowired @@ -162,6 +162,19 @@ public class TodoTaskServiceImpl implements TodoTaskService { operatorId, normalizeOperator(operatorName, operatorId)); } + @Override + @Transactional(rollbackFor = Exception.class) + public int completeByBusinessAsSystem(String businessType, String businessId, Long assigneeUserId, + String operatorName) { + String safeBusinessType = normalizeRequired(businessType, "业务类型", 64); + String safeBusinessId = normalizeRequired(businessId, "业务主键", 64); + if (assigneeUserId != null) { + validateUserId(assigneeUserId); + } + return todoTaskMapper.completeTodoByBusiness(safeBusinessType, safeBusinessId, assigneeUserId, + null, normalizeOperator(operatorName, null)); + } + @Override @Transactional(rollbackFor = Exception.class) public boolean cancelForAssignee(Long todoId, Long assigneeUserId, String operatorName, String reason) {