19 Commits

Author SHA1 Message Date
jsowell
7199228f8c 完成待办中心首期任务跟踪 2026-07-17 18:16:05 +08:00
jsowell
6749a35b68 记录待办安全和性能增强进度 2026-07-17 18:13:49 +08:00
jsowell
1bffb95b18 增强待办路由安全和查询索引 2026-07-17 18:12:53 +08:00
jsowell
fd678c9ab3 记录待办维护任务开发进度 2026-07-17 18:07:12 +08:00
jsowell
b6b052b9df 新增待办过期和历史归档任务 2026-07-17 18:06:21 +08:00
jsowell
09d98eb18d 记录待办管理端开发进度 2026-07-17 18:00:54 +08:00
jsowell
558949f4e1 新增待办管理端异常处理能力 2026-07-17 18:00:02 +08:00
jsowell
27ed7e717b 记录提现开票待办开发进度 2026-07-17 17:49:13 +08:00
jsowell
3e299f40f9 接入运营商提现开票待办闭环 2026-07-17 17:48:14 +08:00
jsowell
bc20330f1b 记录开票待办接收人收敛进度 2026-07-17 17:31:21 +08:00
jsowell
5c7f4dccec 收敛充电订单开票待办接收人 2026-07-17 17:30:29 +08:00
jsowell
c416bdacc9 调整开票待办业务场景和后续任务 2026-07-17 16:03:54 +08:00
jsowell
4720194fe7 记录待办中心间距优化任务 2026-07-17 14:41:26 +08:00
jsowell
de312c934b 更新待办中心任务执行状态 2026-07-16 17:08:15 +08:00
jsowell
cbc2e3c721 接入司机开票申请待办流程 2026-07-16 17:06:49 +08:00
jsowell
974e68cf54 新增待办中心菜单和基础权限脚本 2026-07-16 16:50:18 +08:00
jsowell
f84ec43ceb 新增当前用户待办接口和权限测试 2026-07-16 16:20:34 +08:00
jsowell
181d4a5b40 新增待办任务通用领域服务 2026-07-16 16:11:38 +08:00
jsowell
e65017ab22 新增待办中心任务拆分和建表脚本 2026-07-16 15:56:58 +08:00
57 changed files with 4140 additions and 43 deletions

View File

@@ -55,14 +55,18 @@
## 5. 首期业务范围
### 5.1 开票待办
### 5.1 首期开票待办
首期优先实现截图中的开票场景:
首期建设以下两个真实业务场景:
| 待办类型 | 触发条件 | 处理页面 | 完成条件 |
| --- | --- | --- | --- |
| 平台采购开票 | 平台审核拒绝或要求补充材料 | 发票详情/开票申请页 | 用户重新提交或平台关闭任务 |
| 司机开票申请 | 收到司机开票申请 | 发票申请处理页 | 审核通过、驳回或关闭 |
| 待办类型 | 接收人 | 触发条件 | 处理页面 | 完成条件 |
| --- | --- | --- | --- | --- |
| 充电订单开票申请 | 对应运营商管理员 | 用户完成充电后提交订单开票申请 | 发票申请处理页 | 运营商完成开票、驳回或关闭申请 |
| 运营商提现向平台开票 | 对应运营商管理员 | 运营商提现成功,提现状态变为 `已提现` | 财务详情/提现开票处理页 | 运营商提交发票号码及凭证后自动完成,或平台管理员关闭任务 |
首期接收人按运营商管理员角色解析,不向该运营商部门下所有普通用户广播。当前系统创建运营商时使用 `role_id = 3` 创建运营商管理员,实施时需要在测试库确认该角色的 `role_key`,并将角色配置化。
提现向平台开票按“一笔成功提现对应一条待办”实现。首期新增提现开票信息记录,至少保存发票状态、发票号码、凭证地址、提交人和提交时间;后续如平台允许合并多笔提现开具一张发票,再扩展为批量关联模型。
### 5.2 预留业务类型
@@ -74,7 +78,7 @@
| 清分异常 | 清分失败、分账配置错误或账单待处理 |
| 订单异常 | 订单退款、结算或支付异常需要人工介入 |
首期只实现开票待办,其他类型只完成通用模型和扩展接口,不要求全部接入。
首期只实现上述两个开票待办,其他类型只完成通用模型和扩展接口,不要求全部接入。
## 6. 业务流程
@@ -268,7 +272,7 @@ TodoTaskService.countUnread(Long userId);
```java
todoTaskService.createTask(TodoTaskCreateCommand.builder()
.taskType("INVOICE_REVIEW")
.title("司机开票申请")
.title("充电订单开票申请")
.summary("有新的开票申请需要处理")
.businessType("INVOICE_APPLY")
.businessId(invoiceId.toString())
@@ -329,8 +333,8 @@ todoTaskService.createTask(TodoTaskCreateCommand.builder()
### 首期业务
- 平台采购开票待办可以正常生成、展示、跳转和完成。
- 司机开票申请待办可以正常生成、展示、跳转和完成。
- 用户充电订单开票申请待办可以按运营商管理员生成、展示、跳转和完成。
- 运营商提现成功后可以生成“向平台开票”待办,并在提交发票信息后完成。
- 原有 `sys_notice` 公告功能不受影响。
## 14. 实施拆分
@@ -344,10 +348,11 @@ todoTaskService.createTask(TodoTaskCreateCommand.builder()
### 第二期:开票接入
- 接入平台采购开票事件
- 接入司机开票申请事件
- 配置业务路由和完成条件
- 增加接口和页面联调测试
- 将充电订单开票待办接收人收敛为运营商管理员
- 接入提现成功事件,创建运营商向平台开票待办
- 新增提现开票信息表、提交发票信息接口和处理页面
- 配置两个业务场景的路由和完成条件
- 增加接口、事务和页面联调测试。
### 第三期:平台业务扩展

View File

@@ -0,0 +1,253 @@
# 待办事项中心开发任务跟踪
## 任务概述
- 需求来源:`docs/PRD-待办事项功能.md`
- 开发目标:先交付面向当前登录用户的通用待办闭环,再接入首期开票业务,最后补充管理和扩展能力。
- 后端仓库:当前根仓库,分支 `feature-待办中心`
- 前端仓库:`jsowell-charge-ui/` 独立 Git 仓库,当前分支 `dev`
- 状态定义:`todo` 未开始、`in_progress` 进行中、`blocked` 阻塞、`done` 已完成
## 优先级说明
- P0首期上线必需缺失时无法形成可用闭环。
- P1首期开票业务落地和运营管理必需可在通用能力稳定后接入。
- P2安全、运维和体验增强不阻塞首期核心流程。
- P3后续业务扩展和实时化能力。
## 关键设计决策
### 幂等键生命周期
PRD 同时要求“幂等键唯一”和“任务完成后允许同一业务重新产生任务”。MySQL 普通唯一索引无法同时满足这两个要求,因此采用双字段方案:
- `idempotent_key`:保存业务方传入的原始幂等键,历史记录不修改。
- `active_idempotent_key`:仅非终态任务保存原始幂等键,并建立唯一索引。
- 任务进入 `COMPLETED``CANCELLED``EXPIRED` 后,将 `active_idempotent_key` 置空。
该方案利用 MySQL 唯一索引允许多个 `NULL` 的特性,保证并发创建时只有一条有效待办,同时允许终态后再次创建。
### 数据权限边界
- 用户端接口不接收接收人 ID统一从当前登录态获取用户 ID。
- 用户端详情、已读、完成和取消更新均在 SQL 条件中携带 `assignee_user_id`,避免先查后改产生越权窗口。
- 管理端能力使用独立路径和权限标识,不复用用户端接口绕过接收人过滤。
### 首期两个开票场景
- 充电订单开票:沿用 `order_invoice_record` 创建事件,接收人收敛为对应运营商管理员;通过 `todo.operator-admin-role-id` 配置管理员角色 ID默认沿用现有运营商创建流程的 `role_id = 3`
- 提现向平台开票:监听 `clearing_withdraw_info.withdraw_status` 从处理中变为已提现(`1`)的成功事件;每笔成功提现创建一条 `MERCHANT_WITHDRAW_INVOICE` 待办。
- 提现开票完成不能只依赖手工点完成,新增开票信息记录,保存发票状态、发票号码、凭证地址、提交人和提交时间,提交成功后与待办在同一事务内完成。
### 前后端仓库边界
`jsowell-charge-ui/` 是独立 Git 仓库。后端和前端分别提交中文 commit不将前端目录作为未跟踪目录加入后端仓库。
## 执行批次
| 批次 | 范围 | 优先级 | 状态 | 验收结果 | Commit |
| --- | --- | --- | --- | --- | --- |
| B01 | 任务拆分、数据表和索引 | P0 | done | SQL 结构和索引评审通过 | `e65017ab2` |
| B02 | 实体、命令模型、枚举、Mapper、Service | P0 | done | 模块编译和 Mapper XML 校验通过 | `181d4a5b4` |
| B03 | 当前用户查询、已读、完成、取消接口及测试 | P0 | done | 全量依赖编译、9 个专项测试通过 | `f84ec43ce` |
| B04 | 前端 API、待办中心页面和路由跳转 | P0 | done | ESLint 和生产构建通过 | 前端 `ff6b890` |
| B05 | 首页待办卡片、数量展示和轮询刷新 | P0 | done | ESLint 和生产构建通过 | 前端 `0627061` |
| B05-F1 | 待办中心页面上下间距优化 | P0 | done | ESLint 和生产构建通过 | 前端 `380e2dc` |
| B06 | 菜单权限 SQL、联调和首期回归 | P0 | done | 菜单 SQL、构建和专项回归通过待测试库执行 SQL | `974e68cf5` |
| B07 | 提现成功后向平台开票待办 | P1 | done | 提现回调、开票记录、待办、提交/关闭接口和财务页闭环完成;待测试库执行建表 SQL | 后端 `3e299f40f`;前端 `b16865a` |
| B08 | 充电订单开票申请待办接入 | P1 | done | 已接入业务事件并收敛为运营商管理员接收 | `cbc2e3c72``5c7f4dcce` |
| B08-F1 | 充电订单开票接收人和文案收敛 | P1 | done | 后端编译和 14 个专项测试通过;前端 ESLint、生产构建通过 | 后端 `5c7f4dcce`;前端 `967a9bf` |
| B09 | 管理端创建、分配、取消和隐藏能力 | P1 | done | 全量查询、人工创建、重新分配、取消和终态隐藏接口完成;独立权限默认授予系统管理员 | `558949f4e` |
| B10 | 归档清理、过期任务和操作审计增强 | P2 | done | 到期流转、至少 180 天终态归档和每日维护任务完成;待测试库配置 Quartz | `b6b052b9d` |
| B11 | 路由白名单、性能压测和安全专项验证 | P2 | done | 路由及参数白名单、首页排序索引和验证清单完成;待测试库执行真实数据基准 | `1bffb95b1` |
| B12 | 汇付、清分、订单异常和实时推送扩展 | P3 | todo | - | - |
## 首期交付结论
- P0、P1、P2 开发批次已完成B12 属于 PRD 明确预留、首期不要求接入的后续业务扩展。
- 后端最终专项回归共 30 个测试通过;前端专项 ESLint 和生产构建通过。
- 测试环境部署时按顺序执行或复核:
1. `docs/sql/sys_todo_menu.sql`
2. `docs/sql/clearing_withdraw_invoice.sql`
3. `docs/sql/sys_todo_performance_index.sql`
4. `docs/sql/sys_todo_maintenance_job.sql`
- 登录态、真实提现回调和性能基准按 `docs/plan/2026-07-17-todo-center-security-performance-checklist.md` 执行并记录结果。
## 任务清单
### TODO-001 建表和索引
- 优先级P0
- 批次B01
- 状态done
- 目标:创建 `sys_todo_task` 表,覆盖任务状态、阅读状态、业务路由、接收人、幂等和审计字段。
- 验收标准:
- 有效任务幂等键具备数据库唯一约束。
- 当前用户首页和列表查询具备组合索引。
- 业务单据、运营商范围和历史清理具备查询索引。
- SQL 可重复交付,不包含真实环境配置或数据。
### TODO-002 通用领域模型和服务
- 优先级P0
- 批次B02
- 状态done
- 目标新增实体、创建命令、状态常量、Mapper 和统一 `TodoTaskService`
- 验收标准:
- 创建接口校验必要字段并设置默认状态、优先级和阅读状态。
- 并发重复创建返回已有有效任务,不产生重复记录。
- 完成、取消和过期操作释放有效幂等键并记录操作信息。
- 业务模块仅依赖 Service不直接依赖 Mapper。
### TODO-003 当前用户后端接口
- 优先级P0
- 批次B03
- 状态done
- 目标:实现未完成数量、首页列表、分页列表、详情、已读、全部已读、完成和取消接口。
- 验收标准:
- 所有用户端查询和更新强制使用当前登录用户 ID。
- 首页最多返回 10 条,默认 3 条,按优先级和创建时间倒序。
- 已完成、已取消和已过期任务不计入首页数量。
- 状态流转非法时返回明确业务错误。
- 核心 Service 和 Controller 权限边界有自动化测试。
### TODO-004 待办中心前端页面
- 优先级P0
- 批次B04
- 状态done
- 目标:增加 API 封装、待办中心列表、筛选、分页、批量已读和安全跳转。
- 验收标准:
- 支持任务状态、阅读状态、任务类型和关键字筛选。
- 未读任务有清晰样式,点击后标记已读。
- 跳转只使用后端返回的已配置路由名称,并附带 `businessType``businessId``todoId`
- 已完成记录默认保留展示,不执行物理删除。
### TODO-005 首页待办卡片
- 优先级P0
- 批次B05
- 状态done
- 目标:在运营商工作台首页展示未完成数量和最近 3 条任务。
- 验收标准:
- 数量超过 99 显示 `99+`
- 无任务时显示空状态。
- 页面打开立即加载,并按 60 秒轮询刷新。
- 完成任务或返回首页后主动刷新。
### TODO-006 菜单权限和联调
- 优先级P0
- 批次B06
- 状态done
- 当前进展:
- 菜单、查询、已读、完成和取消权限 SQL 已完成。
- 列表、查询和已读权限默认授予正常角色;完成和取消权限保留为按业务角色授权。
- 后端 9 个专项测试、前端专项 ESLint 和生产构建已通过。
- 待在测试库执行 `docs/sql/sys_todo_menu.sql` 后进行登录态 HTTP 联调。
- 目标:补充菜单与权限 SQL完成前后端构建和核心流程联调。
- 验收标准:
- 普通用户只看到本人待办。
- 无权限用户不能访问管理接口或完成无权业务。
-`sys_notice` 功能和菜单不受影响。
- 后端编译、相关测试、前端 lint/build 通过或记录环境阻塞原因。
### TODO-007 提现成功后向平台开票接入
- 优先级P1
- 批次B07
- 状态done
- 现有入口:`AdapayService.drawCash()` 创建提现记录,提现成功回调 `cashSucceeded()` 将状态更新为 `1`
- 目标:提现成功后为对应运营商管理员创建“向平台开票”待办,并提供提交发票信息后自动完成的闭环。
- 任务拆分:
- 新增提现开票信息表及唯一提现单号约束。
- 在提现成功状态更新事务中创建待办,使用 `MERCHANT_WITHDRAW_INVOICE` 类型。
- 新增查询提现开票详情、提交发票信息、平台关闭任务接口。
- 财务详情页增加待办处理入口,跳转携带 `merchantId``withdrawCode``todoId`
- 完成情况:
- 新增 `clearing_withdraw_invoice` 表脚本,以提现编号唯一约束保证一笔提现只有一条开票记录。
- 提现成功回调在同一事务中更新提现和清分账单状态、创建开票记录,并为运营商管理员幂等创建待办。
- 当前待办接收人可查询并提交发票号码及凭证;提交后同一提现的全部待办自动完成。
- 平台关闭接口复用 `system:todo:cancel` 权限,关闭开票记录并取消关联待办。
- 财务详情页从待办跳转后自动打开开票弹窗,支持上传 PDF 或图片凭证。
- 待在测试库执行 `docs/sql/clearing_withdraw_invoice.sql`,并使用真实提现成功回调完成登录态联调。
- 验收标准:同一提现成功事件对同一管理员只产生一条有效待办;提交发票号码和凭证后待办自动完成;提现失败不创建该待办。
### TODO-008 充电订单开票申请接入
- 优先级P1
- 批次B08
- 状态done
- 目标:收到用户充电订单开票申请时创建待办,运营商完成开票、驳回或关闭时完成。
- 当前实现:已接入 `order_invoice_record` 创建、完成和关闭事件,并与业务事务绑定。
- 当前实现:接收人仅解析运营商部门树下拥有配置角色的正常管理员;默认管理员角色 ID 为 `3`,可通过 `todo.operator-admin-role-id` 覆盖。
- 验收标准:创建和完成与开票业务事务一致,异常回滚时不产生脏任务状态。
### TODO-008-F1 充电订单开票接收人和文案收敛
- 优先级P1
- 批次B08-F1
- 状态done
- 目标:将现有 `INVOICE_REVIEW` 待办的标题、类型文案从“司机开票申请”统一为“充电订单开票申请”,接收人限制为运营商管理员。
- 完成情况:后端创建待办时只查询配置的运营商管理员角色;前端移除不存在的“平台采购开票”筛选和类型文案,统一显示“充电订单开票申请”。
- 验收标准:运营商普通财务或普通操作员不会收到该待办;管理员收到的待办可正常跳转、完成和关闭。
### TODO-009 管理端异常任务处理
- 优先级P1
- 批次B09
- 状态done
- 目标:实现管理员列表、人工创建、分配、取消和逻辑隐藏。
- 完成情况:
- 新增 `/system/todo/admin` 管理接口,覆盖全量分页查询、人工创建、重新分配、管理员取消和终态逻辑隐藏。
- 人工创建和重新分配均校验目标用户为正常用户,并按业务与接收人重建有效幂等键。
- 待处理任务必须先取消才能隐藏,避免隐藏有效任务后幂等键长期占用。
- 新增五项 `system:todo:admin:*` 独立权限,菜单 SQL 默认只授予 `role_key = 'admin'` 的系统管理员。
- 待在测试库重新执行 `docs/sql/sys_todo_menu.sql`,完成管理接口登录态验证。
- 验收标准:接口具备独立权限标识和操作日志,普通用户不能访问。
### TODO-010 运维与安全增强
- 优先级P2
- 批次B10-B11
- 状态done
- 目标补充过期、180 天归档清理、操作审计、路由白名单和性能验证。
- 当前进展:
- B10 已完成:到期待办批量转为 `EXPIRED`,同步记录终态时间并释放有效幂等键。
- B10 已完成:终态任务按 `todo.retention-days` 逻辑归档,配置小于 180 天时强制按 180 天保留。
- B10 已完成:新增 `todoTaskMaintenanceTask.maintain()` 每日维护任务和幂等 Quartz 配置脚本。
- 待在测试库执行 `docs/sql/sys_todo_maintenance_job.sql` 并观察首轮任务日志。
- B11 已完成:待办创建仅允许 `todo.route-whitelist` 中的路由,路由参数限制为安全标量 JSON。
- B11 已完成:新增首页待办排序索引迁移脚本和安全/性能验证清单。
- 待按 `docs/plan/2026-07-17-todo-center-security-performance-checklist.md` 在测试库执行真实数据量 EXPLAIN、P95 和越权验证。
- 验收标准:首页查询目标响应时间小于 500ms路由不能被请求参数任意覆盖历史任务按策略保留。
### TODO-011 后续业务扩展
- 优先级P3
- 批次B12
- 状态todo
- 目标:接入汇付开户、结算账户、清分和订单异常,并评估 WebSocket/SSE。
- 验收标准:新增业务只通过统一待办 Service 接入,不修改通用数据权限和状态模型。
## 提交记录
每完成一个批次,在此追加:完成时间、验证命令、结果和 commit hash。
- 2026-07-16B01完成优先级拆分、任务跟踪文档和 `sys_todo_task` 建表脚本;通过 SQL 人工结构评审与 `git diff --check`commit `e65017ab2`
- 2026-07-16B01 环境进展:用户已在测试数据库创建 `sys_todo_task` 表,可供后续接口联调。
- 2026-07-16B02完成领域模型、创建/查询 DTO、状态常量、Mapper 和统一 Service`mvn -pl jsowell-system -am -DskipTests compile` 与 Mapper XML 语法校验通过commit `181d4a5b4`
- 2026-07-16B03完成当前用户数量、首页列表、分页列表、详情、已读、全部已读、完成和取消接口`mvn -pl jsowell-admin -am -DskipTests compile` 通过9 个 Service/Controller 专项测试通过commit `f84ec43ce`
- 2026-07-16B04完成前端 API、待办中心筛选分页、批量已读和安全路由跳转新增文件专项 ESLint 通过,`NODE_OPTIONS=--openssl-legacy-provider npm run build:prd` 构建通过;前端 commit `ff6b890`
- 2026-07-16B05完成首页右侧待办卡片、`99+` 数量、最近 3 条、空状态、错误重试和 60 秒轮询;专项 ESLint 与生产构建通过;前端 commit `0627061`
- 2026-07-16B06完成幂等菜单/权限 SQL列表、查询和已读权限默认授予正常角色完成和取消权限需按业务角色显式授权后端 9 个专项测试再次通过,前端构建沿用 B05 验证结果commit `974e68cf5`
- 2026-07-16B08司机开票记录创建后按运营商部门树为正常后台用户幂等创建待办开票状态更新为已开票时自动完成删除/关闭记录时自动取消;业务与待办状态处于同一事务;全依赖编译和 12 个专项测试通过commit `cbc2e3c72`
- 2026-07-17B05-F1局部覆盖全局卡片纵向内边距并补充待办页面顶部和底部留白专项 ESLint 与生产构建通过;前端 commit `380e2dc`
- 2026-07-17需求调整移除不存在的平台采购开票场景新增“提现成功后运营商向平台开票”场景并将充电订单开票接收人明确为运营商管理员。
- 2026-07-17B08-F1充电订单开票待办仅分配给配置的运营商管理员角色统一前后端文案并移除不存在的平台采购开票选项后端全依赖编译和 14 个专项测试通过,前端专项 ESLint 与生产构建通过;后端 commit `5c7f4dcce`,前端 commit `967a9bf`
- 2026-07-17B07新增提现开票信息表脚本、提现成功事务闭环、运营商管理员待办、发票详情/提交/关闭接口及财务详情页处理弹窗;后端 20 个相关测试通过,前端新增 API 和待办页面专项 ESLint、生产构建通过后端 commit `3e299f40f`,前端 commit `b16865a`
- 2026-07-17B09新增管理端全量查询、人工创建、重新分配、管理员取消和终态逻辑隐藏接口补充独立权限和系统管理员默认授权全依赖编译、6 个专项测试通过commit `558949f4e`
- 2026-07-17B10新增到期待办自动过期、终态任务至少保留 180 天后逻辑归档、每日 Quartz 维护任务及配置 SQL全依赖编译、8 个相关测试通过commit `b6b052b9d`
- 2026-07-17B11新增可配置路由白名单和安全路由参数校验补充首页排序索引迁移脚本及测试库安全/性能验证清单全依赖编译、24 个相关测试通过,应用 YAML 解析通过commit `1bffb95b1`
- 2026-07-17首期最终回归待办通用服务、两个开票业务、管理端、维护任务和路由安全共 30 个专项测试通过;前端沿用 B07 的专项 ESLint 与生产构建结果P3 预留业务保留在后续迭代。

View File

@@ -0,0 +1,63 @@
# 待办中心安全与性能验证清单
## 部署前 SQL
按顺序在测试库执行:
1. `docs/sql/sys_todo_task.sql`(仅新环境)或 `docs/sql/sys_todo_performance_index.sql`(已有表)。
2. `docs/sql/sys_todo_menu.sql`
3. `docs/sql/clearing_withdraw_invoice.sql`
4. `docs/sql/sys_todo_maintenance_job.sql`
## 路由安全
- 默认仅允许 `invoiceDetail``financeDetail` 两个业务路由。
- 新业务路由上线前必须追加到 `todo.route-whitelist`,不允许直接信任请求体中的路由名。
- `route_params` 必须是 JSON 对象,只允许 `params``query` 两层结构,最终值只能是字符串、数字、布尔值或空值。
- 用户端详情、已读、完成和取消接口继续使用当前登录用户 ID 作为 SQL 条件。
- 管理端接口使用独立的 `system:todo:admin:*` 权限,普通角色不得授权。
## 索引与执行计划
使用具有代表性的数据量执行:
```sql
EXPLAIN
SELECT todo_id, task_type, title, summary, business_type, business_id,
route_name, route_params, priority, task_status, read_status, create_time
FROM sys_todo_task
WHERE assignee_user_id = 100
AND task_status IN ('PENDING', 'PROCESSING')
AND del_flag = '0'
ORDER BY priority DESC, create_time DESC, todo_id DESC
LIMIT 3;
EXPLAIN
SELECT count(1)
FROM sys_todo_task
WHERE assignee_user_id = 100
AND task_status IN ('PENDING', 'PROCESSING')
AND read_status = '0'
AND del_flag = '0';
```
首页列表应命中 `idx_todo_user_active_sort`,未读数量应命中 `idx_todo_user_status`。如果优化器未选择目标索引,执行 `ANALYZE TABLE sys_todo_task` 后复测。
## HTTP 性能抽样
登录测试账号后分别对以下接口执行不少于 100 次请求,记录 P50、P95 和最大响应时间:
- `GET /system/todo/unreadCount`
- `GET /system/todo/homeList?limit=3`
- `GET /system/todo/list?pageNum=1&pageSize=10&taskStatus=ACTIVE`
验收目标:首页数量和最近待办接口 P95 小于 500ms分页接口无全表扫描、无跨用户数据。
## 回归场景
- 修改 `userId``assigneeUserId` 等请求参数不能查看或更新其他用户待办。
- 非白名单路由创建待办时返回明确业务错误。
- 嵌套对象、数组或非法 JSON 的路由参数被拒绝。
- 普通用户访问 `/system/todo/admin/**` 返回无权限。
- 到期待办释放 `active_idempotent_key`,同业务后续可重新生成待办。
- 终态任务在 180 天内不归档,超过保留期后仅逻辑隐藏,不物理删除。

View File

@@ -0,0 +1,33 @@
-- 运营商提现向平台开票信息表
-- 执行前请确认 sys_todo_task、待办菜单权限 SQL 已部署。
CREATE TABLE IF NOT EXISTS `clearing_withdraw_invoice` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`withdraw_code` varchar(64) NOT NULL COMMENT '提现编号',
`merchant_id` varchar(64) NOT NULL COMMENT '运营商ID',
`invoice_status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态PENDING待提交、SUBMITTED已提交、CLOSED已关闭',
`invoice_number` varchar(100) DEFAULT NULL COMMENT '发票号码',
`voucher_url` varchar(1000) DEFAULT NULL COMMENT '发票凭证地址',
`submitted_by` bigint DEFAULT NULL COMMENT '提交人用户ID',
`submitted_name` varchar(64) DEFAULT NULL COMMENT '提交人账号',
`submitted_time` datetime DEFAULT NULL COMMENT '提交时间',
`closed_by` bigint DEFAULT NULL COMMENT '关闭人用户ID',
`closed_name` varchar(64) DEFAULT NULL COMMENT '关闭人账号',
`closed_time` datetime DEFAULT NULL COMMENT '关闭时间',
`close_reason` varchar(500) DEFAULT NULL COMMENT '关闭原因',
`create_by` varchar(64) DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT '' COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '删除标志0正常 1删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_withdraw_invoice_code` (`withdraw_code`),
KEY `idx_withdraw_invoice_merchant_status` (`merchant_id`, `invoice_status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运营商提现向平台开票信息表';
-- 完成权限分配给运营商管理员角色,取消权限只分配给平台任务关闭角色。
-- INSERT INTO sys_role_menu (role_id, menu_id)
-- SELECT <运营商管理员角色ID>, menu_id FROM sys_menu WHERE perms = 'system:todo:complete';
-- INSERT INTO sys_role_menu (role_id, menu_id)
-- SELECT <平台任务关闭角色ID>, menu_id FROM sys_menu WHERE perms = 'system:todo:cancel';

View File

@@ -0,0 +1,19 @@
-- 待办任务过期与历史归档定时任务
-- 默认每天 02:30 执行;归档保留天数通过 todo.retention-days 配置,最少保留 180 天。
INSERT INTO sys_job (
job_name, job_group, invoke_target, cron_expression,
misfire_policy, concurrent, status, create_by, create_time, remark
)
SELECT
'待办过期与历史归档', 'SYSTEM', 'todoTaskMaintenanceTask.maintain()', '0 30 2 * * ?',
'3', '1', '0', 'system', sysdate(), '过期待处理任务并逻辑归档至少180天前的终态任务'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM sys_job
WHERE invoke_target = 'todoTaskMaintenanceTask.maintain()'
);
SELECT job_id, job_name, invoke_target, cron_expression, status
FROM sys_job
WHERE invoke_target = 'todoTaskMaintenanceTask.maintain()';

183
docs/sql/sys_todo_menu.sql Normal file
View File

@@ -0,0 +1,183 @@
-- 待办中心菜单与基础权限
-- 执行效果:
-- 1. 新增可访问 /system/todo 的顶级菜单。
-- 2. 为所有正常角色授予列表、详情和已读权限;接口始终只返回当前登录用户数据。
-- 3. 完成和取消权限默认不授予普通角色,需按业务职责显式分配。
SET @todoMenuId := (
SELECT menu_id
FROM sys_menu
WHERE path = 'system/todo'
AND menu_type = 'C'
LIMIT 1
);
INSERT INTO sys_menu (
menu_name, parent_id, order_num, path, component, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark
)
SELECT
'待办中心', 0, 8, 'system/todo', 'system/todo/index', 1, 0,
'C', '0', '0', 'system:todo:list', 'list', 'system', sysdate(), '当前登录用户待办中心'
FROM DUAL
WHERE @todoMenuId IS NULL;
SET @todoMenuId := COALESCE(@todoMenuId, LAST_INSERT_ID());
SET @todoQueryMenuId := (
SELECT menu_id FROM sys_menu
WHERE parent_id = @todoMenuId AND perms = 'system:todo:query'
LIMIT 1
);
INSERT INTO sys_menu (
menu_name, parent_id, order_num, path, component, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark
)
SELECT
'待办查询', @todoMenuId, 1, '#', '', 1, 0,
'F', '0', '0', 'system:todo:query', '#', 'system', sysdate(), ''
FROM DUAL
WHERE @todoQueryMenuId IS NULL;
SET @todoQueryMenuId := COALESCE(@todoQueryMenuId, LAST_INSERT_ID());
SET @todoReadMenuId := (
SELECT menu_id FROM sys_menu
WHERE parent_id = @todoMenuId AND perms = 'system:todo:read'
LIMIT 1
);
INSERT INTO sys_menu (
menu_name, parent_id, order_num, path, component, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark
)
SELECT
'待办已读', @todoMenuId, 2, '#', '', 1, 0,
'F', '0', '0', 'system:todo:read', '#', 'system', sysdate(), ''
FROM DUAL
WHERE @todoReadMenuId IS NULL;
SET @todoReadMenuId := COALESCE(@todoReadMenuId, LAST_INSERT_ID());
SET @todoCompleteMenuId := (
SELECT menu_id FROM sys_menu
WHERE parent_id = @todoMenuId AND perms = 'system:todo:complete'
LIMIT 1
);
INSERT INTO sys_menu (
menu_name, parent_id, order_num, path, component, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark
)
SELECT
'待办完成', @todoMenuId, 3, '#', '', 1, 0,
'F', '0', '0', 'system:todo:complete', '#', 'system', sysdate(), '仅分配给具备对应业务处理权限的角色'
FROM DUAL
WHERE @todoCompleteMenuId IS NULL;
SET @todoCompleteMenuId := COALESCE(@todoCompleteMenuId, LAST_INSERT_ID());
SET @todoCancelMenuId := (
SELECT menu_id FROM sys_menu
WHERE parent_id = @todoMenuId AND perms = 'system:todo:cancel'
LIMIT 1
);
INSERT INTO sys_menu (
menu_name, parent_id, order_num, path, component, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark
)
SELECT
'待办取消', @todoMenuId, 4, '#', '', 1, 0,
'F', '0', '0', 'system:todo:cancel', '#', 'system', sysdate(), '仅分配给具备关闭任务权限的角色'
FROM DUAL
WHERE @todoCancelMenuId IS NULL;
SET @todoCancelMenuId := COALESCE(@todoCancelMenuId, LAST_INSERT_ID());
-- 所有正常角色均可查看、查询并标记本人的待办。
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT role_id, @todoMenuId
FROM sys_role role_info
WHERE role_info.status = '0'
AND role_info.del_flag = '0'
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu relation
WHERE relation.role_id = role_info.role_id
AND relation.menu_id = @todoMenuId
);
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT role_id, @todoQueryMenuId
FROM sys_role role_info
WHERE role_info.status = '0'
AND role_info.del_flag = '0'
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu relation
WHERE relation.role_id = role_info.role_id
AND relation.menu_id = @todoQueryMenuId
);
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT role_id, @todoReadMenuId
FROM sys_role role_info
WHERE role_info.status = '0'
AND role_info.del_flag = '0'
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu relation
WHERE relation.role_id = role_info.role_id
AND relation.menu_id = @todoReadMenuId
);
-- 完成/取消权限按业务角色手工授权示例:
-- INSERT INTO sys_role_menu (role_id, menu_id) VALUES (<业务处理角色ID>, @todoCompleteMenuId);
-- INSERT INTO sys_role_menu (role_id, menu_id) VALUES (<任务关闭角色ID>, @todoCancelMenuId);
-- 管理端异常任务处理权限,仅默认授予 role_key = 'admin' 的系统管理员。
INSERT INTO sys_menu (
menu_name, parent_id, order_num, path, component, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, remark
)
SELECT permission_info.menu_name, @todoMenuId, permission_info.order_num, '#', '', 1, 0,
'F', '0', '0', permission_info.perms, '#', 'system', sysdate(), permission_info.remark
FROM (
SELECT '待办管理查询' AS menu_name, 11 AS order_num, 'system:todo:admin:list' AS perms, '查看全量待办' AS remark
UNION ALL SELECT '待办人工创建', 12, 'system:todo:admin:create', '人工补发异常待办'
UNION ALL SELECT '待办重新分配', 13, 'system:todo:admin:assign', '重新分配有效待办'
UNION ALL SELECT '待办管理取消', 14, 'system:todo:admin:cancel', '管理员关闭异常待办'
UNION ALL SELECT '待办逻辑隐藏', 15, 'system:todo:admin:remove', '隐藏终态待办记录'
) permission_info
WHERE NOT EXISTS (
SELECT 1 FROM sys_menu existing_menu
WHERE existing_menu.parent_id = @todoMenuId
AND existing_menu.perms = permission_info.perms
);
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT role_info.role_id, menu_info.menu_id
FROM sys_role role_info
JOIN sys_menu menu_info
ON menu_info.parent_id = @todoMenuId
AND menu_info.perms IN (
'system:todo:admin:list',
'system:todo:admin:create',
'system:todo:admin:assign',
'system:todo:admin:cancel',
'system:todo:admin:remove'
)
WHERE role_info.role_key = 'admin'
AND role_info.status = '0'
AND role_info.del_flag = '0'
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu relation
WHERE relation.role_id = role_info.role_id
AND relation.menu_id = menu_info.menu_id
);
SELECT
@todoMenuId AS todo_menu_id,
@todoQueryMenuId AS todo_query_menu_id,
@todoReadMenuId AS todo_read_menu_id,
@todoCompleteMenuId AS todo_complete_menu_id,
@todoCancelMenuId AS todo_cancel_menu_id;

View File

@@ -0,0 +1,20 @@
-- 待办首页列表排序索引(用于已存在的 sys_todo_task 表)
-- MySQL 5.7 兼容的幂等写法。
SET @todoIndexExists := (
SELECT count(1)
FROM information_schema.statistics
WHERE table_schema = database()
AND table_name = 'sys_todo_task'
AND index_name = 'idx_todo_user_active_sort'
);
SET @todoIndexSql := IF(
@todoIndexExists = 0,
'ALTER TABLE sys_todo_task ADD INDEX idx_todo_user_active_sort (assignee_user_id, del_flag, task_status, priority, create_time, todo_id)',
'SELECT ''idx_todo_user_active_sort already exists'''
);
PREPARE todoIndexStatement FROM @todoIndexSql;
EXECUTE todoIndexStatement;
DEALLOCATE PREPARE todoIndexStatement;

View File

@@ -0,0 +1,40 @@
-- 待办事项中心任务表
-- 说明active_idempotent_key 仅在非终态任务中保留,终态时置空,
-- 用于同时满足“有效任务唯一”和“终态后允许重新创建”的要求。
CREATE TABLE `sys_todo_task` (
`todo_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '待办任务主键',
`task_type` varchar(64) NOT NULL COMMENT '待办类型编码',
`title` varchar(200) NOT NULL COMMENT '待办标题',
`summary` varchar(500) DEFAULT NULL COMMENT '待办摘要',
`content` text COMMENT '待办详细内容(纯文本)',
`business_type` varchar(64) NOT NULL COMMENT '业务类型编码',
`business_id` varchar(64) NOT NULL COMMENT '业务主键或单号',
`route_name` varchar(100) NOT NULL COMMENT '前端白名单路由名称',
`route_params` varchar(1000) DEFAULT NULL COMMENT '前端路由参数 JSON',
`assignee_user_id` bigint(20) NOT NULL COMMENT '接收用户 ID',
`assignee_merchant_id` varchar(64) DEFAULT NULL COMMENT '接收运营商 ID',
`priority` tinyint(3) NOT NULL DEFAULT '1' COMMENT '优先级1普通 2重要 3紧急',
`task_status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态PENDING PROCESSING COMPLETED CANCELLED EXPIRED',
`read_status` char(1) NOT NULL DEFAULT '0' COMMENT '阅读状态0未读 1已读',
`read_time` datetime DEFAULT NULL COMMENT '阅读时间',
`due_time` datetime DEFAULT NULL COMMENT '截止时间',
`completed_time` datetime DEFAULT NULL COMMENT '完成时间',
`completed_by` bigint(20) DEFAULT NULL COMMENT '完成人用户 ID',
`cancel_reason` varchar(500) DEFAULT NULL COMMENT '取消原因',
`idempotent_key` varchar(200) NOT NULL COMMENT '业务原始幂等键',
`active_idempotent_key` varchar(200) DEFAULT NULL COMMENT '有效任务幂等键,终态置空',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除0正常 2删除',
PRIMARY KEY (`todo_id`) USING BTREE,
UNIQUE KEY `uk_todo_active_idempotent` (`active_idempotent_key`) USING BTREE,
KEY `idx_todo_user_status` (`assignee_user_id`, `task_status`, `read_status`, `priority`, `create_time`) USING BTREE,
KEY `idx_todo_user_active_sort` (`assignee_user_id`, `del_flag`, `task_status`, `priority`, `create_time`, `todo_id`) USING BTREE,
KEY `idx_todo_merchant_status` (`assignee_merchant_id`, `task_status`, `create_time`) USING BTREE,
KEY `idx_todo_business` (`business_type`, `business_id`) USING BTREE,
KEY `idx_todo_history_cleanup` (`del_flag`, `task_status`, `completed_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='用户待办任务表';

View File

@@ -124,6 +124,9 @@ public class OrderService {
@Autowired
private ClearingWithdrawInfoService clearingWithdrawInfoService;
@Autowired
private MerchantWithdrawInvoiceService merchantWithdrawInvoiceService;
@Autowired
private MemberAdapayRecordService memberAdapayRecordService;
@@ -1464,22 +1467,7 @@ public class OrderService {
log.info("取现成功 data:{}", JSON.toJSONString(data));
JSONObject jsonObject = JSON.parseObject(data);
String withdrawCode = jsonObject.getString("id");
// 通过取现id查询取现数据
ClearingWithdrawInfo clearingWithdrawInfo = clearingWithdrawInfoService.selectByWithdrawCode(withdrawCode);
if (clearingWithdrawInfo != null) {
clearingWithdrawInfo.setWithdrawStatus(Constants.ONE);
clearingWithdrawInfo.setUpdateTime(DateUtils.getNowDate());
clearingWithdrawInfoService.updateByPrimaryKeySelective(clearingWithdrawInfo);
}
// 清分账单数据更新
List<ClearingBillInfo> clearingBillInfos = clearingBillInfoService.selectByWithdrawCode(withdrawCode);
if (CollectionUtils.isNotEmpty(clearingBillInfos)) {
for (ClearingBillInfo clearingBillInfo : clearingBillInfos) {
clearingBillInfo.setBillStatus("4");
}
clearingBillInfoService.updateBatchSelective(clearingBillInfos);
}
merchantWithdrawInvoiceService.handleWithdrawSucceeded(withdrawCode);
}
/**

View File

@@ -0,0 +1,54 @@
package com.jsowell.web.controller.pile;
import com.jsowell.common.annotation.Log;
import com.jsowell.common.core.controller.BaseController;
import com.jsowell.common.core.domain.AjaxResult;
import com.jsowell.common.enums.BusinessType;
import com.jsowell.pile.dto.WithdrawInvoiceCloseRequest;
import com.jsowell.pile.dto.WithdrawInvoiceSubmitRequest;
import com.jsowell.pile.service.MerchantWithdrawInvoiceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 运营商提现向平台开票接口。
*/
@RestController
@RequestMapping("/pile/withdrawInvoice")
public class MerchantWithdrawInvoiceController extends BaseController {
@Autowired
private MerchantWithdrawInvoiceService merchantWithdrawInvoiceService;
@PreAuthorize("@ss.hasPermi('system:todo:query')")
@GetMapping("/{withdrawCode}")
public AjaxResult getInfo(@PathVariable String withdrawCode, @RequestParam Long todoId) {
return AjaxResult.success(merchantWithdrawInvoiceService.getForAssignee(
withdrawCode, todoId, getUserId()));
}
@PreAuthorize("@ss.hasPermi('system:todo:complete')")
@Log(title = "运营商提现开票", businessType = BusinessType.UPDATE)
@PutMapping("/{withdrawCode}/submit")
public AjaxResult submit(@PathVariable String withdrawCode,
@Validated @RequestBody WithdrawInvoiceSubmitRequest request) {
return toAjax(merchantWithdrawInvoiceService.submit(withdrawCode, request,
getUserId(), getUsername()));
}
@PreAuthorize("@ss.hasPermi('system:todo:cancel')")
@Log(title = "运营商提现开票", businessType = BusinessType.UPDATE)
@PutMapping("/{withdrawCode}/close")
public AjaxResult close(@PathVariable String withdrawCode,
@Validated @RequestBody WithdrawInvoiceCloseRequest request) {
return toAjax(merchantWithdrawInvoiceService.close(withdrawCode, request.getReason(),
getUserId(), getUsername()));
}
}

View File

@@ -90,7 +90,7 @@ public class OrderInvoiceRecordController extends BaseController {
@Log(title = "申请开票", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody OrderInvoiceRecord orderInvoiceRecord) {
return toAjax(orderInvoiceRecordService.updateOrderInvoiceRecord(orderInvoiceRecord));
return toAjax(orderInvoiceRecordService.updateOrderInvoiceRecord(orderInvoiceRecord, getUserId(), getUsername()));
}
/**
@@ -100,6 +100,6 @@ public class OrderInvoiceRecordController extends BaseController {
@Log(title = "申请开票", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Integer[] ids) {
return toAjax(orderInvoiceRecordService.deleteOrderInvoiceRecordByIds(ids));
return toAjax(orderInvoiceRecordService.deleteOrderInvoiceRecordByIds(ids, getUsername()));
}
}

View File

@@ -0,0 +1,74 @@
package com.jsowell.web.controller.system;
import com.jsowell.common.annotation.Log;
import com.jsowell.common.core.controller.BaseController;
import com.jsowell.common.core.domain.AjaxResult;
import com.jsowell.common.core.page.TableDataInfo;
import com.jsowell.common.enums.BusinessType;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskAdminCreateRequest;
import com.jsowell.system.domain.dto.TodoTaskAdminQuery;
import com.jsowell.system.domain.dto.TodoTaskAssignRequest;
import com.jsowell.system.domain.dto.TodoTaskCancelRequest;
import com.jsowell.system.service.TodoTaskAdminService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 待办管理端接口。
*/
@RestController
@RequestMapping("/system/todo/admin")
public class SysTodoTaskAdminController extends BaseController {
@Autowired
private TodoTaskAdminService todoTaskAdminService;
@PreAuthorize("@ss.hasPermi('system:todo:admin:list')")
@GetMapping("/list")
public TableDataInfo list(TodoTaskAdminQuery query) {
startPage();
List<SysTodoTask> list = todoTaskAdminService.list(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('system:todo:admin:create')")
@Log(title = "待办管理", businessType = BusinessType.INSERT)
@PostMapping("/create")
public AjaxResult create(@Validated @RequestBody TodoTaskAdminCreateRequest request) {
return AjaxResult.success(todoTaskAdminService.create(request, getUsername()));
}
@PreAuthorize("@ss.hasPermi('system:todo:admin:assign')")
@Log(title = "待办管理", businessType = BusinessType.UPDATE)
@PutMapping("/{todoId}/assign")
public AjaxResult assign(@PathVariable Long todoId,
@Validated @RequestBody TodoTaskAssignRequest request) {
return toAjax(todoTaskAdminService.assign(todoId, request, getUsername()));
}
@PreAuthorize("@ss.hasPermi('system:todo:admin:cancel')")
@Log(title = "待办管理", businessType = BusinessType.UPDATE)
@PutMapping("/{todoId}/cancel")
public AjaxResult cancel(@PathVariable Long todoId,
@Validated @RequestBody TodoTaskCancelRequest request) {
return toAjax(todoTaskAdminService.cancel(todoId, getUserId(), getUsername(), request.getReason()));
}
@PreAuthorize("@ss.hasPermi('system:todo:admin:remove')")
@Log(title = "待办管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{todoId}")
public AjaxResult hide(@PathVariable Long todoId) {
return toAjax(todoTaskAdminService.hide(todoId, getUsername()));
}
}

View File

@@ -0,0 +1,118 @@
package com.jsowell.web.controller.system;
import com.jsowell.common.annotation.Log;
import com.jsowell.common.core.controller.BaseController;
import com.jsowell.common.core.domain.AjaxResult;
import com.jsowell.common.core.page.TableDataInfo;
import com.jsowell.common.enums.BusinessType;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskCancelRequest;
import com.jsowell.system.domain.dto.TodoTaskQuery;
import com.jsowell.system.service.TodoTaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 当前登录用户待办任务接口。
*
* @author jsowell
*/
@RestController
@RequestMapping("/system/todo")
public class SysTodoTaskController extends BaseController {
@Autowired
private TodoTaskService todoTaskService;
/**
* 获取当前用户待办数量。
*/
@PreAuthorize("@ss.hasPermi('system:todo:list')")
@GetMapping("/unreadCount")
public AjaxResult unreadCount() {
Long userId = getUserId();
Map<String, Integer> counts = new HashMap<>(2);
counts.put("activeCount", todoTaskService.countActive(userId));
counts.put("unreadCount", todoTaskService.countUnread(userId));
return AjaxResult.success(counts);
}
/**
* 获取首页最近待办。
*/
@PreAuthorize("@ss.hasPermi('system:todo:list')")
@GetMapping("/homeList")
public AjaxResult homeList(@RequestParam(required = false) Integer limit) {
return AjaxResult.success(todoTaskService.homeList(getUserId(), limit));
}
/**
* 分页查询当前用户待办。
*/
@PreAuthorize("@ss.hasPermi('system:todo:list')")
@GetMapping("/list")
public TableDataInfo list(TodoTaskQuery query) {
startPage();
List<SysTodoTask> list = todoTaskService.listForAssignee(query, getUserId());
return getDataTable(list);
}
/**
* 查询当前用户待办详情。
*/
@PreAuthorize("@ss.hasPermi('system:todo:query')")
@GetMapping("/{todoId}")
public AjaxResult getInfo(@PathVariable Long todoId) {
return AjaxResult.success(todoTaskService.getForAssignee(todoId, getUserId()));
}
/**
* 标记当前用户的一条待办为已读。
*/
@PreAuthorize("@ss.hasPermi('system:todo:read')")
@PutMapping("/{todoId}/read")
public AjaxResult markRead(@PathVariable Long todoId) {
return toAjax(todoTaskService.markRead(todoId, getUserId(), getUsername()));
}
/**
* 标记当前用户全部待办为已读。
*/
@PreAuthorize("@ss.hasPermi('system:todo:read')")
@PutMapping("/readAll")
public AjaxResult markAllRead() {
return AjaxResult.success(todoTaskService.markAllRead(getUserId(), getUsername()));
}
/**
* 完成当前用户待办。
*/
@PreAuthorize("@ss.hasPermi('system:todo:complete')")
@Log(title = "待办任务", businessType = BusinessType.UPDATE)
@PutMapping("/{todoId}/complete")
public AjaxResult complete(@PathVariable Long todoId) {
return toAjax(todoTaskService.complete(todoId, getUserId(), getUsername()));
}
/**
* 取消当前用户待办。
*/
@PreAuthorize("@ss.hasPermi('system:todo:cancel')")
@Log(title = "待办任务", businessType = BusinessType.UPDATE)
@PutMapping("/{todoId}/cancel")
public AjaxResult cancel(@PathVariable Long todoId,
@Validated @RequestBody TodoTaskCancelRequest request) {
return toAjax(todoTaskService.cancelForAssignee(todoId, getUserId(), getUsername(), request.getReason()));
}
}

View File

@@ -214,3 +214,9 @@ sms:
enable: false
host: 127.0.0.1
port: 8080
# 待办中心
todo:
operator-admin-role-id: 3
retention-days: 180
route-whitelist: invoiceDetail,financeDetail

View File

@@ -0,0 +1,174 @@
package com.jsowell.pile.service;
import com.jsowell.common.constant.Constants;
import com.jsowell.pile.constant.MerchantWithdrawInvoiceConstants;
import com.jsowell.pile.domain.ClearingBillInfo;
import com.jsowell.pile.domain.ClearingWithdrawInfo;
import com.jsowell.pile.domain.ClearingWithdrawInvoice;
import com.jsowell.pile.domain.PileMerchantInfo;
import com.jsowell.pile.dto.WithdrawInvoiceSubmitRequest;
import com.jsowell.pile.mapper.ClearingWithdrawInfoMapper;
import com.jsowell.pile.mapper.ClearingWithdrawInvoiceMapper;
import com.jsowell.pile.service.impl.MerchantWithdrawInvoiceServiceImpl;
import com.jsowell.system.domain.SysTodoTask;
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.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MerchantWithdrawInvoiceServiceImplTest {
@Test
void handleSucceeded_shouldUpdateWithdrawAndCreateAdminTodos() {
Dependencies dependencies = new Dependencies();
MerchantWithdrawInvoiceServiceImpl service = dependencies.createService();
ClearingWithdrawInfo withdrawInfo = withdrawInfo(Constants.ZERO);
when(dependencies.withdrawInfoMapper.selectByWithdrawCode("W100")).thenReturn(withdrawInfo);
when(dependencies.invoiceMapper.selectByWithdrawCode("W100")).thenReturn(null);
when(dependencies.invoiceMapper.insertSelective(any(ClearingWithdrawInvoice.class))).thenReturn(1);
ClearingBillInfo billInfo = new ClearingBillInfo();
billInfo.setId(5);
when(dependencies.clearingBillInfoService.selectByWithdrawCode("W100"))
.thenReturn(Collections.singletonList(billInfo));
PileMerchantInfo merchantInfo = new PileMerchantInfo();
merchantInfo.setDeptId("200");
when(dependencies.pileMerchantInfoService.selectPileMerchantInfoById(100L)).thenReturn(merchantInfo);
when(dependencies.assigneeService.findActiveOperatorAdminUserIdsByDeptTree(200L))
.thenReturn(Arrays.asList(8L, 9L));
service.handleWithdrawSucceeded("W100");
verify(dependencies.withdrawInfoMapper).updateByPrimaryKeySelective(withdrawInfo);
assertEquals(Constants.ONE, withdrawInfo.getWithdrawStatus());
assertEquals("4", billInfo.getBillStatus());
verify(dependencies.clearingBillInfoService).updateBatchSelective(Collections.singletonList(billInfo));
ArgumentCaptor<TodoTaskCreateCommand> captor = ArgumentCaptor.forClass(TodoTaskCreateCommand.class);
verify(dependencies.todoTaskService, times(2)).createTask(captor.capture());
assertEquals(Arrays.asList(8L, 9L), Arrays.asList(
captor.getAllValues().get(0).getAssigneeUserId(),
captor.getAllValues().get(1).getAssigneeUserId()));
assertEquals(MerchantWithdrawInvoiceConstants.TASK_TYPE, captor.getValue().getTaskType());
assertEquals(MerchantWithdrawInvoiceConstants.BUSINESS_TYPE, captor.getValue().getBusinessType());
assertEquals("financeDetail", captor.getValue().getRouteName());
}
@Test
void handleSucceeded_shouldNotRecreateTodoAfterInvoiceSubmitted() {
Dependencies dependencies = new Dependencies();
MerchantWithdrawInvoiceServiceImpl service = dependencies.createService();
when(dependencies.withdrawInfoMapper.selectByWithdrawCode("W100"))
.thenReturn(withdrawInfo(Constants.ONE));
ClearingWithdrawInvoice invoice = invoice(MerchantWithdrawInvoiceConstants.STATUS_SUBMITTED);
when(dependencies.invoiceMapper.selectByWithdrawCode("W100")).thenReturn(invoice);
when(dependencies.clearingBillInfoService.selectByWithdrawCode("W100"))
.thenReturn(Collections.emptyList());
service.handleWithdrawSucceeded("W100");
verify(dependencies.todoTaskService, never()).createTask(any(TodoTaskCreateCommand.class));
}
@Test
void submit_shouldCompleteAllBusinessTodos() {
Dependencies dependencies = new Dependencies();
MerchantWithdrawInvoiceServiceImpl service = dependencies.createService();
when(dependencies.todoTaskService.getForAssignee(20L, 8L)).thenReturn(todoTask());
when(dependencies.invoiceMapper.selectByWithdrawCode("W100"))
.thenReturn(invoice(MerchantWithdrawInvoiceConstants.STATUS_PENDING));
when(dependencies.invoiceMapper.markSubmitted(any(ClearingWithdrawInvoice.class))).thenReturn(1);
WithdrawInvoiceSubmitRequest request = new WithdrawInvoiceSubmitRequest();
request.setTodoId(20L);
request.setInvoiceNumber("INV-100");
request.setVoucherUrl("/profile/upload/invoice.pdf");
assertTrue(service.submit("W100", request, 8L, "operator"));
verify(dependencies.todoTaskService).completeByBusiness(
MerchantWithdrawInvoiceConstants.BUSINESS_TYPE, "W100", null, 8L, "operator");
}
@Test
void close_shouldCancelAllBusinessTodos() {
Dependencies dependencies = new Dependencies();
MerchantWithdrawInvoiceServiceImpl service = dependencies.createService();
when(dependencies.invoiceMapper.selectByWithdrawCode("W100"))
.thenReturn(invoice(MerchantWithdrawInvoiceConstants.STATUS_PENDING));
when(dependencies.invoiceMapper.markClosed(any(ClearingWithdrawInvoice.class))).thenReturn(1);
assertTrue(service.close("W100", "无需开票", 1L, "admin"));
verify(dependencies.todoTaskService).cancelByBusiness(
MerchantWithdrawInvoiceConstants.BUSINESS_TYPE, "W100", null, "admin", "无需开票");
}
private static ClearingWithdrawInfo withdrawInfo(String status) {
ClearingWithdrawInfo withdrawInfo = new ClearingWithdrawInfo();
withdrawInfo.setId(1);
withdrawInfo.setMerchantId("100");
withdrawInfo.setWithdrawCode("W100");
withdrawInfo.setWithdrawStatus(status);
return withdrawInfo;
}
private static ClearingWithdrawInvoice invoice(String status) {
ClearingWithdrawInvoice invoice = new ClearingWithdrawInvoice();
invoice.setId(10L);
invoice.setMerchantId("100");
invoice.setWithdrawCode("W100");
invoice.setInvoiceStatus(status);
return invoice;
}
private static SysTodoTask todoTask() {
SysTodoTask task = new SysTodoTask();
task.setTodoId(20L);
task.setTaskType(MerchantWithdrawInvoiceConstants.TASK_TYPE);
task.setBusinessType(MerchantWithdrawInvoiceConstants.BUSINESS_TYPE);
task.setBusinessId("W100");
task.setTaskStatus("PENDING");
return task;
}
private static void setField(Object target, String fieldName, Object value) {
try {
Field field = MerchantWithdrawInvoiceServiceImpl.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
}
private static class Dependencies {
private final ClearingWithdrawInfoMapper withdrawInfoMapper = mock(ClearingWithdrawInfoMapper.class);
private final ClearingWithdrawInvoiceMapper invoiceMapper = mock(ClearingWithdrawInvoiceMapper.class);
private final ClearingBillInfoService clearingBillInfoService = mock(ClearingBillInfoService.class);
private final PileMerchantInfoService pileMerchantInfoService = mock(PileMerchantInfoService.class);
private final TodoTaskAssigneeService assigneeService = mock(TodoTaskAssigneeService.class);
private final TodoTaskService todoTaskService = mock(TodoTaskService.class);
private MerchantWithdrawInvoiceServiceImpl createService() {
MerchantWithdrawInvoiceServiceImpl service = new MerchantWithdrawInvoiceServiceImpl();
setField(service, "clearingWithdrawInfoMapper", withdrawInfoMapper);
setField(service, "clearingWithdrawInvoiceMapper", invoiceMapper);
setField(service, "clearingBillInfoService", clearingBillInfoService);
setField(service, "pileMerchantInfoService", pileMerchantInfoService);
setField(service, "todoTaskAssigneeService", assigneeService);
setField(service, "todoTaskService", todoTaskService);
return service;
}
}
}

View File

@@ -0,0 +1,119 @@
package com.jsowell.pile.service;
import com.jsowell.pile.domain.OrderInvoiceRecord;
import com.jsowell.pile.domain.PileMerchantInfo;
import com.jsowell.pile.mapper.OrderInvoiceRecordMapper;
import com.jsowell.pile.service.impl.OrderInvoiceRecordServiceImpl;
import com.jsowell.system.constant.TodoTaskConstants;
import com.jsowell.system.domain.SysTodoTask;
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.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class OrderInvoiceRecordTodoTest {
@Test
void insert_shouldCreateTodoForEachMerchantAdmin() {
OrderInvoiceRecordMapper mapper = mock(OrderInvoiceRecordMapper.class);
PileMerchantInfoService merchantService = mock(PileMerchantInfoService.class);
TodoTaskAssigneeService assigneeService = mock(TodoTaskAssigneeService.class);
TodoTaskService todoTaskService = mock(TodoTaskService.class);
OrderInvoiceRecordServiceImpl service = newService(mapper, merchantService, assigneeService, todoTaskService);
OrderInvoiceRecord invoiceRecord = new OrderInvoiceRecord();
invoiceRecord.setMerchantId("100");
doAnswer(invocation -> {
OrderInvoiceRecord record = invocation.getArgument(0);
record.setId(55);
return 1;
}).when(mapper).insertOrderInvoiceRecord(invoiceRecord);
PileMerchantInfo merchantInfo = new PileMerchantInfo();
merchantInfo.setDeptId("200");
when(merchantService.selectPileMerchantInfoById(100L)).thenReturn(merchantInfo);
when(assigneeService.findActiveOperatorAdminUserIdsByDeptTree(200L)).thenReturn(Arrays.asList(8L, 9L));
when(todoTaskService.createTask(any(TodoTaskCreateCommand.class))).thenReturn(new SysTodoTask());
service.insertOrderInvoiceRecord(invoiceRecord);
ArgumentCaptor<TodoTaskCreateCommand> captor = ArgumentCaptor.forClass(TodoTaskCreateCommand.class);
verify(todoTaskService, times(2)).createTask(captor.capture());
List<TodoTaskCreateCommand> commands = captor.getAllValues();
assertEquals(Arrays.asList(8L, 9L), Arrays.asList(
commands.get(0).getAssigneeUserId(), commands.get(1).getAssigneeUserId()));
assertEquals("ORDER_INVOICE_RECORD", commands.get(0).getBusinessType());
assertEquals("55", commands.get(0).getBusinessId());
assertEquals("充电订单开票申请", commands.get(0).getTitle());
assertEquals("有新的充电订单开票申请需要处理申请单号55", commands.get(0).getSummary());
assertEquals("invoiceDetail", commands.get(0).getRouteName());
assertEquals(TodoTaskConstants.PRIORITY_IMPORTANT, commands.get(0).getPriority());
assertEquals("INVOICE_REVIEW:ORDER_INVOICE_RECORD:55:8", commands.get(0).getIdempotentKey());
}
@Test
void updateCompleted_shouldCompleteBusinessTodos() {
OrderInvoiceRecordMapper mapper = mock(OrderInvoiceRecordMapper.class);
TodoTaskService todoTaskService = mock(TodoTaskService.class);
OrderInvoiceRecordServiceImpl service = newService(mapper, mock(PileMerchantInfoService.class),
mock(TodoTaskAssigneeService.class), todoTaskService);
OrderInvoiceRecord invoiceRecord = new OrderInvoiceRecord();
invoiceRecord.setId(55);
invoiceRecord.setStatus("1");
when(mapper.updateOrderInvoiceRecord(invoiceRecord)).thenReturn(1);
service.updateOrderInvoiceRecord(invoiceRecord, 1L, "admin");
verify(todoTaskService).completeByBusiness("ORDER_INVOICE_RECORD", "55", null, 1L, "admin");
}
@Test
void delete_shouldCancelBusinessTodos() {
OrderInvoiceRecordMapper mapper = mock(OrderInvoiceRecordMapper.class);
TodoTaskService todoTaskService = mock(TodoTaskService.class);
OrderInvoiceRecordServiceImpl service = newService(mapper, mock(PileMerchantInfoService.class),
mock(TodoTaskAssigneeService.class), todoTaskService);
Integer[] ids = { 55, 56 };
when(mapper.deleteOrderInvoiceRecordByIds(ids)).thenReturn(2);
service.deleteOrderInvoiceRecordByIds(ids, "admin");
verify(todoTaskService).cancelByBusiness("ORDER_INVOICE_RECORD", "55", null,
"admin", "开票申请已关闭");
verify(todoTaskService).cancelByBusiness("ORDER_INVOICE_RECORD", "56", null,
"admin", "开票申请已关闭");
}
private static OrderInvoiceRecordServiceImpl newService(OrderInvoiceRecordMapper mapper,
PileMerchantInfoService merchantService, TodoTaskAssigneeService assigneeService,
TodoTaskService todoTaskService) {
OrderInvoiceRecordServiceImpl service = new OrderInvoiceRecordServiceImpl();
setField(service, "orderInvoiceRecordMapper", mapper);
setField(service, "pileMerchantInfoService", merchantService);
setField(service, "todoTaskAssigneeService", assigneeService);
setField(service, "todoTaskService", todoTaskService);
return service;
}
private static void setField(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
}
}

View File

@@ -0,0 +1,36 @@
package com.jsowell.quartz.task;
import com.jsowell.system.domain.dto.TodoTaskMaintenanceResult;
import com.jsowell.system.service.TodoTaskMaintenanceService;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.Date;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TodoTaskMaintenanceTaskTest {
@Test
void maintain_shouldDelegateToMaintenanceService() {
TodoTaskMaintenanceService service = mock(TodoTaskMaintenanceService.class);
TodoTaskMaintenanceTask task = new TodoTaskMaintenanceTask();
setField(task, "todoTaskMaintenanceService", service);
when(service.maintain()).thenReturn(new TodoTaskMaintenanceResult(1, 2, new Date()));
task.maintain();
verify(service).maintain();
}
private static void setField(Object target, String fieldName, Object value) {
try {
Field field = TodoTaskMaintenanceTask.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
}
}

View File

@@ -0,0 +1,146 @@
package com.jsowell.system.service;
import com.jsowell.common.constant.Constants;
import com.jsowell.common.core.domain.entity.SysUser;
import com.jsowell.common.exception.ServiceException;
import com.jsowell.system.constant.TodoTaskConstants;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskAdminCreateRequest;
import com.jsowell.system.domain.dto.TodoTaskAdminQuery;
import com.jsowell.system.domain.dto.TodoTaskAssignRequest;
import com.jsowell.system.domain.dto.TodoTaskCreateCommand;
import com.jsowell.system.mapper.SysTodoTaskMapper;
import com.jsowell.system.mapper.SysUserMapper;
import com.jsowell.system.service.impl.TodoTaskAdminServiceImpl;
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.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
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 TodoTaskAdminServiceImplTest {
@Test
void list_shouldUseAdminQueryWithoutForcingAssignee() {
Dependencies dependencies = new Dependencies();
TodoTaskAdminServiceImpl service = dependencies.createService();
TodoTaskAdminQuery query = new TodoTaskAdminQuery();
query.setTaskStatus(TodoTaskConstants.STATUS_ACTIVE);
when(dependencies.todoTaskMapper.selectAdminTodoList(query))
.thenReturn(Collections.singletonList(new SysTodoTask()));
assertEquals(1, service.list(query).size());
verify(dependencies.todoTaskMapper).selectAdminTodoList(query);
}
@Test
void create_shouldValidateUserAndGenerateIdempotentKey() {
Dependencies dependencies = new Dependencies();
TodoTaskAdminServiceImpl service = dependencies.createService();
when(dependencies.userMapper.selectUserById(8L)).thenReturn(activeUser(8L));
when(dependencies.todoTaskService.createTask(any(TodoTaskCreateCommand.class)))
.thenReturn(new SysTodoTask());
TodoTaskAdminCreateRequest request = createRequest();
service.create(request, "admin");
ArgumentCaptor<TodoTaskCreateCommand> captor = ArgumentCaptor.forClass(TodoTaskCreateCommand.class);
verify(dependencies.todoTaskService).createTask(captor.capture());
assertEquals("MANUAL_TASK:MANUAL_BUSINESS:B100:8", captor.getValue().getIdempotentKey());
assertEquals("admin", captor.getValue().getCreateBy());
}
@Test
void assign_shouldUpdateActiveTaskAndResetAssignmentKey() {
Dependencies dependencies = new Dependencies();
TodoTaskAdminServiceImpl service = dependencies.createService();
when(dependencies.userMapper.selectUserById(9L)).thenReturn(activeUser(9L));
SysTodoTask task = task(TodoTaskConstants.STATUS_PENDING);
when(dependencies.todoTaskMapper.selectTodoById(20L)).thenReturn(task);
when(dependencies.todoTaskMapper.assignTodo(20L, 9L, "100",
"MANUAL_TASK:MANUAL_BUSINESS:B100:9", "admin")).thenReturn(1);
TodoTaskAssignRequest request = new TodoTaskAssignRequest();
request.setAssigneeUserId(9L);
request.setAssigneeMerchantId("100");
assertTrue(service.assign(20L, request, "admin"));
verify(dependencies.todoTaskMapper).assignTodo(20L, 9L, "100",
"MANUAL_TASK:MANUAL_BUSINESS:B100:9", "admin");
}
@Test
void hide_shouldOnlyAllowTerminalTask() {
Dependencies dependencies = new Dependencies();
TodoTaskAdminServiceImpl service = dependencies.createService();
when(dependencies.todoTaskMapper.selectTodoById(20L))
.thenReturn(task(TodoTaskConstants.STATUS_PENDING));
assertThrows(ServiceException.class, () -> service.hide(20L, "admin"));
when(dependencies.todoTaskMapper.selectTodoById(21L))
.thenReturn(task(TodoTaskConstants.STATUS_CANCELLED));
when(dependencies.todoTaskMapper.hideTodo(21L, "admin")).thenReturn(1);
assertTrue(service.hide(21L, "admin"));
}
private static TodoTaskAdminCreateRequest createRequest() {
TodoTaskAdminCreateRequest request = new TodoTaskAdminCreateRequest();
request.setTaskType("MANUAL_TASK");
request.setTitle("人工待办");
request.setBusinessType("MANUAL_BUSINESS");
request.setBusinessId("B100");
request.setRouteName("TodoCenter");
request.setAssigneeUserId(8L);
return request;
}
private static SysTodoTask task(String status) {
SysTodoTask task = new SysTodoTask();
task.setTodoId(20L);
task.setTaskType("MANUAL_TASK");
task.setBusinessType("MANUAL_BUSINESS");
task.setBusinessId("B100");
task.setTaskStatus(status);
return task;
}
private static SysUser activeUser(Long userId) {
SysUser user = new SysUser();
user.setUserId(userId);
user.setStatus(Constants.ZERO);
user.setDelFlag(Constants.ZERO);
return user;
}
private static void setField(Object target, String fieldName, Object value) {
try {
Field field = TodoTaskAdminServiceImpl.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
}
private static class Dependencies {
private final SysTodoTaskMapper todoTaskMapper = mock(SysTodoTaskMapper.class);
private final SysUserMapper userMapper = mock(SysUserMapper.class);
private final TodoTaskService todoTaskService = mock(TodoTaskService.class);
private TodoTaskAdminServiceImpl createService() {
TodoTaskAdminServiceImpl service = new TodoTaskAdminServiceImpl();
setField(service, "todoTaskMapper", todoTaskMapper);
setField(service, "userMapper", userMapper);
setField(service, "todoTaskService", todoTaskService);
return service;
}
}
}

View File

@@ -0,0 +1,52 @@
package com.jsowell.system.service;
import com.jsowell.system.mapper.SysUserMapper;
import com.jsowell.system.service.impl.TodoTaskAssigneeServiceImpl;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TodoTaskAssigneeServiceImplTest {
@Test
void findOperatorAdmins_shouldUseConfiguredRole() {
SysUserMapper mapper = mock(SysUserMapper.class);
TodoTaskAssigneeServiceImpl service = new TodoTaskAssigneeServiceImpl();
setField(service, "userMapper", mapper);
setField(service, "operatorAdminRoleId", 7L);
when(mapper.selectActiveUserIdsByDeptTreeAndRole(200L, 7L))
.thenReturn(Collections.singletonList(8L));
assertEquals(Collections.singletonList(8L),
service.findActiveOperatorAdminUserIdsByDeptTree(200L));
verify(mapper).selectActiveUserIdsByDeptTreeAndRole(200L, 7L);
}
@Test
void findOperatorAdmins_shouldFallbackToExistingMerchantAdminRole() {
SysUserMapper mapper = mock(SysUserMapper.class);
TodoTaskAssigneeServiceImpl service = new TodoTaskAssigneeServiceImpl();
setField(service, "userMapper", mapper);
when(mapper.selectActiveUserIdsByDeptTreeAndRole(200L, 3L))
.thenReturn(Collections.singletonList(8L));
service.findActiveOperatorAdminUserIdsByDeptTree(200L);
verify(mapper).selectActiveUserIdsByDeptTreeAndRole(200L, 3L);
}
private static void setField(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.jsowell.system.service;
import com.jsowell.system.domain.dto.TodoTaskMaintenanceResult;
import com.jsowell.system.mapper.SysTodoTaskMapper;
import com.jsowell.system.service.impl.TodoTaskMaintenanceServiceImpl;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.lang.reflect.Field;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
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 TodoTaskMaintenanceServiceImplTest {
@Test
void maintain_shouldExpireAndKeepAtLeast180Days() {
SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class);
TodoTaskMaintenanceServiceImpl service = new TodoTaskMaintenanceServiceImpl();
setField(service, "todoTaskMapper", mapper);
setField(service, "retentionDays", 30);
when(mapper.expireDueTasks(any(Date.class), any(String.class))).thenReturn(2);
when(mapper.archiveTerminalTasks(any(Date.class), any(String.class))).thenReturn(3);
TodoTaskMaintenanceResult result = service.maintain();
assertEquals(2, result.getExpiredCount());
assertEquals(3, result.getArchivedCount());
ArgumentCaptor<Date> nowCaptor = ArgumentCaptor.forClass(Date.class);
ArgumentCaptor<Date> archiveCaptor = ArgumentCaptor.forClass(Date.class);
verify(mapper).expireDueTasks(nowCaptor.capture(), any(String.class));
verify(mapper).archiveTerminalTasks(archiveCaptor.capture(), any(String.class));
long retentionDays = (nowCaptor.getValue().getTime() - archiveCaptor.getValue().getTime())
/ (24L * 60 * 60 * 1000);
assertTrue(retentionDays >= 179);
}
private static void setField(Object target, String fieldName, Object value) {
try {
Field field = TodoTaskMaintenanceServiceImpl.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
}
}

View File

@@ -0,0 +1,171 @@
package com.jsowell.system.service;
import com.jsowell.common.exception.ServiceException;
import com.jsowell.system.constant.TodoTaskConstants;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskCreateCommand;
import com.jsowell.system.domain.dto.TodoTaskQuery;
import com.jsowell.system.mapper.SysTodoTaskMapper;
import com.jsowell.system.service.impl.TodoTaskServiceImpl;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import java.lang.reflect.Field;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TodoTaskServiceImplTest {
@Test
void createTask_shouldSetDefaultsAndInsert() {
SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class);
TodoTaskServiceImpl service = newService(mapper);
TodoTaskCreateCommand command = createCommand();
when(mapper.selectActiveByIdempotentKey("INVOICE:100:8")).thenReturn(null);
doAnswer(invocation -> {
SysTodoTask task = invocation.getArgument(0);
task.setTodoId(11L);
return 1;
}).when(mapper).insertTodoTask(any(SysTodoTask.class));
SysTodoTask result = service.createTask(command);
ArgumentCaptor<SysTodoTask> captor = ArgumentCaptor.forClass(SysTodoTask.class);
verify(mapper).insertTodoTask(captor.capture());
SysTodoTask inserted = captor.getValue();
assertEquals(11L, result.getTodoId());
assertEquals(TodoTaskConstants.PRIORITY_NORMAL, inserted.getPriority());
assertEquals(TodoTaskConstants.STATUS_PENDING, inserted.getTaskStatus());
assertEquals(TodoTaskConstants.READ_UNREAD, inserted.getReadStatus());
assertEquals(inserted.getIdempotentKey(), inserted.getActiveIdempotentKey());
}
@Test
void createTask_shouldReturnExistingTask() {
SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class);
TodoTaskServiceImpl service = newService(mapper);
SysTodoTask existing = new SysTodoTask();
existing.setTodoId(12L);
when(mapper.selectActiveByIdempotentKey("INVOICE:100:8")).thenReturn(existing);
SysTodoTask result = service.createTask(createCommand());
assertSame(existing, result);
verify(mapper, never()).insertTodoTask(any(SysTodoTask.class));
}
@Test
void createTask_shouldRecoverFromConcurrentDuplicate() {
SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class);
TodoTaskServiceImpl service = newService(mapper);
SysTodoTask existing = new SysTodoTask();
existing.setTodoId(13L);
when(mapper.selectActiveByIdempotentKey("INVOICE:100:8")).thenReturn(null, existing);
doThrow(new DuplicateKeyException("duplicate"))
.when(mapper).insertTodoTask(any(SysTodoTask.class));
SysTodoTask result = service.createTask(createCommand());
assertSame(existing, result);
}
@Test
void listForAssignee_shouldOverrideRequestedUser() {
SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class);
TodoTaskServiceImpl service = newService(mapper);
TodoTaskQuery query = new TodoTaskQuery();
query.setAssigneeUserId(999L);
when(mapper.selectTodoList(query)).thenReturn(Collections.emptyList());
service.listForAssignee(query, 8L);
assertEquals(8L, query.getAssigneeUserId());
verify(mapper).selectTodoList(query);
}
@Test
void complete_shouldBeIdempotentWhenAlreadyCompleted() {
SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class);
TodoTaskServiceImpl service = newService(mapper);
SysTodoTask completed = new SysTodoTask();
completed.setTaskStatus(TodoTaskConstants.STATUS_COMPLETED);
when(mapper.completeTodoForAssignee(20L, 8L, 8L, "tester")).thenReturn(0);
when(mapper.selectTodoByIdForAssignee(20L, 8L)).thenReturn(completed);
assertTrue(service.complete(20L, 8L, "tester"));
}
@Test
void complete_shouldRejectCancelledTask() {
SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class);
TodoTaskServiceImpl service = newService(mapper);
SysTodoTask cancelled = new SysTodoTask();
cancelled.setTaskStatus(TodoTaskConstants.STATUS_CANCELLED);
when(mapper.completeTodoForAssignee(20L, 8L, 8L, "tester")).thenReturn(0);
when(mapper.selectTodoByIdForAssignee(20L, 8L)).thenReturn(cancelled);
assertThrows(ServiceException.class, () -> service.complete(20L, 8L, "tester"));
}
@Test
void createTask_shouldRejectRouteOutsideWhitelist() {
SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class);
TodoTaskServiceImpl service = newService(mapper);
TodoTaskCreateCommand command = createCommand();
command.setRouteName("UnsafeRoute");
assertThrows(ServiceException.class, () -> service.createTask(command));
verify(mapper, never()).insertTodoTask(any(SysTodoTask.class));
}
@Test
void createTask_shouldRejectNestedRouteParams() {
SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class);
TodoTaskServiceImpl service = newService(mapper);
TodoTaskCreateCommand command = createCommand();
command.setRouteParams("{\"query\":{\"payload\":{\"admin\":true}}}");
assertThrows(ServiceException.class, () -> service.createTask(command));
verify(mapper, never()).insertTodoTask(any(SysTodoTask.class));
}
private static TodoTaskCreateCommand createCommand() {
return TodoTaskCreateCommand.builder()
.taskType("INVOICE_REVIEW")
.title("充电订单开票申请")
.businessType("INVOICE_APPLY")
.businessId("100")
.routeName("invoiceDetail")
.assigneeUserId(8L)
.idempotentKey("INVOICE:100:8")
.createBy("tester")
.build();
}
private static TodoTaskServiceImpl newService(SysTodoTaskMapper mapper) {
TodoTaskServiceImpl service = new TodoTaskServiceImpl();
setField(service, "todoTaskMapper", mapper);
return service;
}
private static void setField(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
}
}

View File

@@ -0,0 +1,78 @@
package com.jsowell.web.controller.pile;
import com.jsowell.common.core.domain.AjaxResult;
import com.jsowell.pile.dto.WithdrawInvoiceSubmitRequest;
import com.jsowell.pile.service.MerchantWithdrawInvoiceService;
import com.jsowell.pile.vo.web.MerchantWithdrawInvoiceVO;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MerchantWithdrawInvoiceControllerTest {
@Test
void getInfo_shouldUseCurrentLoginUser() {
MerchantWithdrawInvoiceService service = mock(MerchantWithdrawInvoiceService.class);
TestController controller = newController(service, 8L, "operator");
MerchantWithdrawInvoiceVO view = new MerchantWithdrawInvoiceVO();
when(service.getForAssignee("W100", 20L, 8L)).thenReturn(view);
AjaxResult result = controller.getInfo("W100", 20L);
assertSame(view, result.get(AjaxResult.DATA_TAG));
verify(service).getForAssignee("W100", 20L, 8L);
}
@Test
void submit_shouldPassCurrentUserAndOperator() {
MerchantWithdrawInvoiceService service = mock(MerchantWithdrawInvoiceService.class);
TestController controller = newController(service, 8L, "operator");
WithdrawInvoiceSubmitRequest request = new WithdrawInvoiceSubmitRequest();
request.setTodoId(20L);
request.setInvoiceNumber("INV-100");
request.setVoucherUrl("/invoice.pdf");
when(service.submit("W100", request, 8L, "operator")).thenReturn(true);
controller.submit("W100", request);
verify(service).submit("W100", request, 8L, "operator");
}
private static TestController newController(MerchantWithdrawInvoiceService service,
Long userId, String username) {
TestController controller = new TestController(userId, username);
try {
Field field = MerchantWithdrawInvoiceController.class
.getDeclaredField("merchantWithdrawInvoiceService");
field.setAccessible(true);
field.set(controller, service);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
return controller;
}
private static class TestController extends MerchantWithdrawInvoiceController {
private final Long userId;
private final String username;
private TestController(Long userId, String username) {
this.userId = userId;
this.username = username;
}
@Override
public Long getUserId() {
return userId;
}
@Override
public String getUsername() {
return username;
}
}
}

View File

@@ -0,0 +1,73 @@
package com.jsowell.web.controller.system;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskAdminCreateRequest;
import com.jsowell.system.domain.dto.TodoTaskCancelRequest;
import com.jsowell.system.service.TodoTaskAdminService;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SysTodoTaskAdminControllerTest {
@Test
void create_shouldPassCurrentOperator() {
TodoTaskAdminService service = mock(TodoTaskAdminService.class);
TestController controller = newController(service, 1L, "admin");
TodoTaskAdminCreateRequest request = new TodoTaskAdminCreateRequest();
when(service.create(request, "admin")).thenReturn(new SysTodoTask());
controller.create(request);
verify(service).create(request, "admin");
}
@Test
void cancel_shouldPassCurrentUserAndOperator() {
TodoTaskAdminService service = mock(TodoTaskAdminService.class);
TestController controller = newController(service, 1L, "admin");
TodoTaskCancelRequest request = new TodoTaskCancelRequest();
request.setReason("异常任务关闭");
when(service.cancel(20L, 1L, "admin", "异常任务关闭")).thenReturn(true);
controller.cancel(20L, request);
verify(service).cancel(20L, 1L, "admin", "异常任务关闭");
}
private static TestController newController(TodoTaskAdminService service,
Long userId, String username) {
TestController controller = new TestController(userId, username);
try {
Field field = SysTodoTaskAdminController.class.getDeclaredField("todoTaskAdminService");
field.setAccessible(true);
field.set(controller, service);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
return controller;
}
private static class TestController extends SysTodoTaskAdminController {
private final Long userId;
private final String username;
private TestController(Long userId, String username) {
this.userId = userId;
this.username = username;
}
@Override
public Long getUserId() {
return userId;
}
@Override
public String getUsername() {
return username;
}
}
}

View File

@@ -0,0 +1,93 @@
package com.jsowell.web.controller.system;
import com.jsowell.common.core.domain.AjaxResult;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.service.TodoTaskService;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SysTodoTaskControllerTest {
@Test
@SuppressWarnings("unchecked")
void unreadCount_shouldUseCurrentLoginUser() {
TodoTaskService service = mock(TodoTaskService.class);
TestController controller = newController(service, 8L, "tester");
when(service.countActive(8L)).thenReturn(5);
when(service.countUnread(8L)).thenReturn(3);
AjaxResult result = controller.unreadCount();
Map<String, Integer> data = (Map<String, Integer>) result.get(AjaxResult.DATA_TAG);
assertEquals(5, data.get("activeCount"));
assertEquals(3, data.get("unreadCount"));
}
@Test
void getInfo_shouldNotAcceptAnotherAssignee() {
TodoTaskService service = mock(TodoTaskService.class);
TestController controller = newController(service, 8L, "tester");
SysTodoTask task = new SysTodoTask();
task.setTodoId(20L);
when(service.getForAssignee(20L, 8L)).thenReturn(task);
AjaxResult result = controller.getInfo(20L);
assertSame(task, result.get(AjaxResult.DATA_TAG));
verify(service).getForAssignee(20L, 8L);
}
@Test
void complete_shouldPassCurrentUserAndOperator() {
TodoTaskService service = mock(TodoTaskService.class);
TestController controller = newController(service, 8L, "tester");
when(service.complete(20L, 8L, "tester")).thenReturn(true);
controller.complete(20L);
verify(service).complete(20L, 8L, "tester");
}
private static TestController newController(TodoTaskService service, Long userId, String username) {
TestController controller = new TestController(userId, username);
setField(controller, "todoTaskService", service);
return controller;
}
private static void setField(Object target, String fieldName, Object value) {
try {
Field field = SysTodoTaskController.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
}
private static class TestController extends SysTodoTaskController {
private final Long userId;
private final String username;
private TestController(Long userId, String username) {
this.userId = userId;
this.username = username;
}
@Override
public Long getUserId() {
return userId;
}
@Override
public String getUsername() {
return username;
}
}
}

View File

@@ -0,0 +1,17 @@
package com.jsowell.pile.constant;
/**
* 运营商提现开票常量。
*/
public final class MerchantWithdrawInvoiceConstants {
public static final String TASK_TYPE = "MERCHANT_WITHDRAW_INVOICE";
public static final String BUSINESS_TYPE = "CLEARING_WITHDRAW_INVOICE";
public static final String ROUTE_NAME = "financeDetail";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_SUBMITTED = "SUBMITTED";
public static final String STATUS_CLOSED = "CLOSED";
private MerchantWithdrawInvoiceConstants() {
}
}

View File

@@ -0,0 +1,36 @@
package com.jsowell.pile.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.jsowell.common.core.domain.BaseEntity;
import lombok.Data;
import java.util.Date;
/**
* 运营商提现开票信息 clearing_withdraw_invoice。
*/
@Data
public class ClearingWithdrawInvoice extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long id;
private String withdrawCode;
private String merchantId;
private String invoiceStatus;
private String invoiceNumber;
private String voucherUrl;
private Long submittedBy;
private String submittedName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date submittedTime;
private Long closedBy;
private String closedName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date closedTime;
private String closeReason;
private String delFlag;
}

View File

@@ -0,0 +1,16 @@
package com.jsowell.pile.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* 平台关闭提现开票任务请求。
*/
@Data
public class WithdrawInvoiceCloseRequest {
@NotBlank(message = "关闭原因不能为空")
@Size(max = 500, message = "关闭原因不能超过500个字符")
private String reason;
}

View File

@@ -0,0 +1,24 @@
package com.jsowell.pile.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 提交提现发票信息请求。
*/
@Data
public class WithdrawInvoiceSubmitRequest {
@NotNull(message = "待办任务 ID 不能为空")
private Long todoId;
@NotBlank(message = "发票号码不能为空")
@Size(max = 100, message = "发票号码不能超过100个字符")
private String invoiceNumber;
@NotBlank(message = "发票凭证不能为空")
@Size(max = 1000, message = "发票凭证地址不能超过1000个字符")
private String voucherUrl;
}

View File

@@ -0,0 +1,17 @@
package com.jsowell.pile.mapper;
import com.jsowell.pile.domain.ClearingWithdrawInvoice;
import org.apache.ibatis.annotations.Param;
/**
* 运营商提现开票信息 Mapper。
*/
public interface ClearingWithdrawInvoiceMapper {
ClearingWithdrawInvoice selectByWithdrawCode(@Param("withdrawCode") String withdrawCode);
int insertSelective(ClearingWithdrawInvoice invoice);
int markSubmitted(ClearingWithdrawInvoice invoice);
int markClosed(ClearingWithdrawInvoice invoice);
}

View File

@@ -0,0 +1,18 @@
package com.jsowell.pile.service;
import com.jsowell.pile.dto.WithdrawInvoiceSubmitRequest;
import com.jsowell.pile.vo.web.MerchantWithdrawInvoiceVO;
/**
* 运营商提现向平台开票服务。
*/
public interface MerchantWithdrawInvoiceService {
void handleWithdrawSucceeded(String withdrawCode);
MerchantWithdrawInvoiceVO getForAssignee(String withdrawCode, Long todoId, Long userId);
boolean submit(String withdrawCode, WithdrawInvoiceSubmitRequest request,
Long userId, String operatorName);
boolean close(String withdrawCode, String reason, Long operatorId, String operatorName);
}

View File

@@ -61,7 +61,7 @@ public interface OrderInvoiceRecordService {
* @param orderInvoiceRecord 申请开票
* @return 结果
*/
public int updateOrderInvoiceRecord(OrderInvoiceRecord orderInvoiceRecord);
public int updateOrderInvoiceRecord(OrderInvoiceRecord orderInvoiceRecord, Long operatorId, String operatorName);
/**
* 批量删除申请开票
@@ -69,7 +69,7 @@ public interface OrderInvoiceRecordService {
* @param ids 需要删除的申请开票主键集合
* @return 结果
*/
public int deleteOrderInvoiceRecordByIds(Integer[] ids);
public int deleteOrderInvoiceRecordByIds(Integer[] ids, String operatorName);
/**
* 删除申请开票信息
@@ -77,5 +77,5 @@ public interface OrderInvoiceRecordService {
* @param id 申请开票主键
* @return 结果
*/
public int deleteOrderInvoiceRecordById(Integer id);
public int deleteOrderInvoiceRecordById(Integer id, String operatorName);
}

View File

@@ -0,0 +1,332 @@
package com.jsowell.pile.service.impl;
import com.alibaba.fastjson2.JSON;
import com.jsowell.common.constant.Constants;
import com.jsowell.common.exception.ServiceException;
import com.jsowell.common.util.DateUtils;
import com.jsowell.common.util.StringUtils;
import com.jsowell.pile.constant.MerchantWithdrawInvoiceConstants;
import com.jsowell.pile.domain.ClearingBillInfo;
import com.jsowell.pile.domain.ClearingWithdrawInfo;
import com.jsowell.pile.domain.ClearingWithdrawInvoice;
import com.jsowell.pile.domain.PileMerchantInfo;
import com.jsowell.pile.dto.WithdrawInvoiceSubmitRequest;
import com.jsowell.pile.mapper.ClearingWithdrawInfoMapper;
import com.jsowell.pile.mapper.ClearingWithdrawInvoiceMapper;
import com.jsowell.pile.service.ClearingBillInfoService;
import com.jsowell.pile.service.MerchantWithdrawInvoiceService;
import com.jsowell.pile.service.PileMerchantInfoService;
import com.jsowell.pile.vo.web.MerchantWithdrawInvoiceVO;
import com.jsowell.system.constant.TodoTaskConstants;
import com.jsowell.system.domain.SysTodoTask;
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.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 运营商提现向平台开票服务实现。
*/
@Service
public class MerchantWithdrawInvoiceServiceImpl implements MerchantWithdrawInvoiceService {
private static final Logger log = LoggerFactory.getLogger(MerchantWithdrawInvoiceServiceImpl.class);
@Resource
private ClearingWithdrawInfoMapper clearingWithdrawInfoMapper;
@Resource
private ClearingWithdrawInvoiceMapper clearingWithdrawInvoiceMapper;
@Autowired
private ClearingBillInfoService clearingBillInfoService;
@Autowired
private PileMerchantInfoService pileMerchantInfoService;
@Autowired
private TodoTaskAssigneeService todoTaskAssigneeService;
@Autowired
private TodoTaskService todoTaskService;
@Override
@Transactional(rollbackFor = Exception.class)
public void handleWithdrawSucceeded(String withdrawCode) {
String safeWithdrawCode = normalizeRequired(withdrawCode, "提现编号", 64);
ClearingWithdrawInfo withdrawInfo = clearingWithdrawInfoMapper.selectByWithdrawCode(safeWithdrawCode);
if (withdrawInfo == null) {
throw new ServiceException("提现记录不存在,无法创建开票待办");
}
Date now = DateUtils.getNowDate();
if (!Constants.ONE.equals(withdrawInfo.getWithdrawStatus()) || withdrawInfo.getArrivalTime() == null) {
withdrawInfo.setWithdrawStatus(Constants.ONE);
withdrawInfo.setArrivalTime(now);
withdrawInfo.setUpdateTime(now);
clearingWithdrawInfoMapper.updateByPrimaryKeySelective(withdrawInfo);
}
completeClearingBills(safeWithdrawCode);
ClearingWithdrawInvoice invoice = getOrCreateInvoice(withdrawInfo);
if (!MerchantWithdrawInvoiceConstants.STATUS_PENDING.equals(invoice.getInvoiceStatus())) {
return;
}
createTodos(withdrawInfo);
}
@Override
public MerchantWithdrawInvoiceVO getForAssignee(String withdrawCode, Long todoId, Long userId) {
String safeWithdrawCode = normalizeRequired(withdrawCode, "提现编号", 64);
validateTodo(safeWithdrawCode, todoId, userId);
ClearingWithdrawInvoice invoice = requireInvoice(safeWithdrawCode);
ClearingWithdrawInfo withdrawInfo = clearingWithdrawInfoMapper.selectByWithdrawCode(safeWithdrawCode);
return buildView(invoice, withdrawInfo);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submit(String withdrawCode, WithdrawInvoiceSubmitRequest request,
Long userId, String operatorName) {
if (request == null) {
throw new ServiceException("发票提交参数不能为空");
}
String safeWithdrawCode = normalizeRequired(withdrawCode, "提现编号", 64);
SysTodoTask todoTask = validateTodo(safeWithdrawCode, request.getTodoId(), userId);
ClearingWithdrawInvoice invoice = requireInvoice(safeWithdrawCode);
if (MerchantWithdrawInvoiceConstants.STATUS_SUBMITTED.equals(invoice.getInvoiceStatus())) {
return true;
}
if (!TodoTaskConstants.isActiveStatus(todoTask.getTaskStatus())) {
throw new ServiceException("当前待办状态不允许提交发票");
}
if (!MerchantWithdrawInvoiceConstants.STATUS_PENDING.equals(invoice.getInvoiceStatus())) {
throw new ServiceException("当前提现开票状态不允许提交");
}
Date now = DateUtils.getNowDate();
invoice.setInvoiceStatus(MerchantWithdrawInvoiceConstants.STATUS_SUBMITTED);
invoice.setInvoiceNumber(normalizeRequired(request.getInvoiceNumber(), "发票号码", 100));
invoice.setVoucherUrl(normalizeRequired(request.getVoucherUrl(), "发票凭证", 1000));
invoice.setSubmittedBy(userId);
invoice.setSubmittedName(normalizeOperator(operatorName, userId));
invoice.setSubmittedTime(now);
invoice.setUpdateBy(invoice.getSubmittedName());
invoice.setUpdateTime(now);
if (clearingWithdrawInvoiceMapper.markSubmitted(invoice) == 0) {
ClearingWithdrawInvoice current = requireInvoice(safeWithdrawCode);
if (MerchantWithdrawInvoiceConstants.STATUS_SUBMITTED.equals(current.getInvoiceStatus())) {
return true;
}
throw new ServiceException("当前提现开票状态不允许提交");
}
todoTaskService.completeByBusiness(MerchantWithdrawInvoiceConstants.BUSINESS_TYPE,
safeWithdrawCode, null, userId, invoice.getSubmittedName());
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean close(String withdrawCode, String reason, Long operatorId, String operatorName) {
String safeWithdrawCode = normalizeRequired(withdrawCode, "提现编号", 64);
if (operatorId == null || operatorId <= 0) {
throw new ServiceException("关闭操作人不能为空");
}
ClearingWithdrawInvoice invoice = requireInvoice(safeWithdrawCode);
if (MerchantWithdrawInvoiceConstants.STATUS_CLOSED.equals(invoice.getInvoiceStatus())) {
return true;
}
if (!MerchantWithdrawInvoiceConstants.STATUS_PENDING.equals(invoice.getInvoiceStatus())) {
throw new ServiceException("当前提现开票状态不允许关闭");
}
Date now = DateUtils.getNowDate();
String safeOperator = normalizeOperator(operatorName, operatorId);
String safeReason = normalizeRequired(reason, "关闭原因", 500);
invoice.setInvoiceStatus(MerchantWithdrawInvoiceConstants.STATUS_CLOSED);
invoice.setClosedBy(operatorId);
invoice.setClosedName(safeOperator);
invoice.setClosedTime(now);
invoice.setCloseReason(safeReason);
invoice.setUpdateBy(safeOperator);
invoice.setUpdateTime(now);
if (clearingWithdrawInvoiceMapper.markClosed(invoice) == 0) {
ClearingWithdrawInvoice current = requireInvoice(safeWithdrawCode);
if (MerchantWithdrawInvoiceConstants.STATUS_CLOSED.equals(current.getInvoiceStatus())) {
return true;
}
throw new ServiceException("当前提现开票状态不允许关闭");
}
todoTaskService.cancelByBusiness(MerchantWithdrawInvoiceConstants.BUSINESS_TYPE,
safeWithdrawCode, null, safeOperator, safeReason);
return true;
}
private void completeClearingBills(String withdrawCode) {
List<ClearingBillInfo> billInfos = clearingBillInfoService.selectByWithdrawCode(withdrawCode);
if (CollectionUtils.isEmpty(billInfos)) {
return;
}
for (ClearingBillInfo billInfo : billInfos) {
billInfo.setBillStatus("4");
}
clearingBillInfoService.updateBatchSelective(billInfos);
}
private ClearingWithdrawInvoice getOrCreateInvoice(ClearingWithdrawInfo withdrawInfo) {
ClearingWithdrawInvoice invoice = clearingWithdrawInvoiceMapper.selectByWithdrawCode(withdrawInfo.getWithdrawCode());
if (invoice != null) {
return invoice;
}
invoice = new ClearingWithdrawInvoice();
invoice.setWithdrawCode(withdrawInfo.getWithdrawCode());
invoice.setMerchantId(withdrawInfo.getMerchantId());
invoice.setInvoiceStatus(MerchantWithdrawInvoiceConstants.STATUS_PENDING);
invoice.setCreateBy("adapay-callback");
invoice.setDelFlag("0");
try {
clearingWithdrawInvoiceMapper.insertSelective(invoice);
return invoice;
} catch (DuplicateKeyException exception) {
invoice = clearingWithdrawInvoiceMapper.selectByWithdrawCode(withdrawInfo.getWithdrawCode());
if (invoice != null) {
return invoice;
}
throw exception;
}
}
private void createTodos(ClearingWithdrawInfo withdrawInfo) {
if (StringUtils.isBlank(withdrawInfo.getMerchantId())) {
log.warn("提现记录缺少运营商无法创建开票待办withdrawCode={}", withdrawInfo.getWithdrawCode());
return;
}
PileMerchantInfo merchantInfo;
try {
merchantInfo = pileMerchantInfoService.selectPileMerchantInfoById(Long.parseLong(withdrawInfo.getMerchantId()));
} catch (NumberFormatException exception) {
log.warn("提现记录运营商 ID 非法无法创建开票待办withdrawCode={}, merchantId={}",
withdrawInfo.getWithdrawCode(), withdrawInfo.getMerchantId());
return;
}
if (merchantInfo == null || StringUtils.isBlank(merchantInfo.getDeptId())) {
log.warn("提现记录运营商未配置部门无法创建开票待办withdrawCode={}, merchantId={}",
withdrawInfo.getWithdrawCode(), withdrawInfo.getMerchantId());
return;
}
Long deptId;
try {
deptId = Long.parseLong(merchantInfo.getDeptId());
} catch (NumberFormatException exception) {
log.warn("提现记录运营商部门 ID 非法无法创建开票待办withdrawCode={}, deptId={}",
withdrawInfo.getWithdrawCode(), merchantInfo.getDeptId());
return;
}
List<Long> assigneeUserIds = todoTaskAssigneeService.findActiveOperatorAdminUserIdsByDeptTree(deptId);
if (CollectionUtils.isEmpty(assigneeUserIds)) {
log.warn("提现记录运营商部门下没有正常管理员无法创建开票待办withdrawCode={}, deptId={}",
withdrawInfo.getWithdrawCode(), deptId);
return;
}
String routeParams = buildRouteParams(withdrawInfo);
for (Long assigneeUserId : assigneeUserIds) {
String idempotentKey = MerchantWithdrawInvoiceConstants.TASK_TYPE + ":"
+ MerchantWithdrawInvoiceConstants.BUSINESS_TYPE + ":"
+ withdrawInfo.getWithdrawCode() + ":" + assigneeUserId;
todoTaskService.createTask(TodoTaskCreateCommand.builder()
.taskType(MerchantWithdrawInvoiceConstants.TASK_TYPE)
.title("运营商提现向平台开票")
.summary("提现已成功,请向平台提交发票,提现编号:" + withdrawInfo.getWithdrawCode())
.businessType(MerchantWithdrawInvoiceConstants.BUSINESS_TYPE)
.businessId(withdrawInfo.getWithdrawCode())
.routeName(MerchantWithdrawInvoiceConstants.ROUTE_NAME)
.routeParams(routeParams)
.assigneeUserId(assigneeUserId)
.assigneeMerchantId(withdrawInfo.getMerchantId())
.priority(TodoTaskConstants.PRIORITY_IMPORTANT)
.idempotentKey(idempotentKey)
.createBy("adapay-callback")
.build());
}
}
private String buildRouteParams(ClearingWithdrawInfo withdrawInfo) {
Map<String, Object> query = new HashMap<>(2);
query.put("merchantId", withdrawInfo.getMerchantId());
query.put("withdrawCode", withdrawInfo.getWithdrawCode());
Map<String, Object> routeParams = new HashMap<>(1);
routeParams.put("query", query);
return JSON.toJSONString(routeParams);
}
private SysTodoTask validateTodo(String withdrawCode, Long todoId, Long userId) {
SysTodoTask todoTask = todoTaskService.getForAssignee(todoId, userId);
if (!MerchantWithdrawInvoiceConstants.TASK_TYPE.equals(todoTask.getTaskType())
|| !MerchantWithdrawInvoiceConstants.BUSINESS_TYPE.equals(todoTask.getBusinessType())
|| !withdrawCode.equals(todoTask.getBusinessId())) {
throw new ServiceException("待办任务与提现开票信息不匹配");
}
return todoTask;
}
private ClearingWithdrawInvoice requireInvoice(String withdrawCode) {
ClearingWithdrawInvoice invoice = clearingWithdrawInvoiceMapper.selectByWithdrawCode(withdrawCode);
if (invoice == null) {
throw new ServiceException("提现开票信息不存在");
}
return invoice;
}
private MerchantWithdrawInvoiceVO buildView(ClearingWithdrawInvoice invoice,
ClearingWithdrawInfo withdrawInfo) {
MerchantWithdrawInvoiceVO view = new MerchantWithdrawInvoiceVO();
BeanUtils.copyProperties(invoice, view);
if (withdrawInfo != null) {
view.setWithdrawAmt(withdrawInfo.getWithdrawAmt());
view.setFeeAmt(withdrawInfo.getFeeAmt());
view.setCreditedAmt(withdrawInfo.getCreditedAmt());
view.setApplicationTime(withdrawInfo.getApplicationTime());
view.setArrivalTime(withdrawInfo.getArrivalTime());
}
return view;
}
private String normalizeRequired(String value, String fieldName, int maxLength) {
String safeValue = StringUtils.trim(value);
if (StringUtils.isEmpty(safeValue)) {
throw new ServiceException(fieldName + "不能为空");
}
if (safeValue.length() > maxLength) {
throw new ServiceException(fieldName + "不能超过" + maxLength + "个字符");
}
return safeValue;
}
private String normalizeOperator(String operatorName, Long operatorId) {
String safeOperator = StringUtils.isNotEmpty(operatorName)
? operatorName.trim() : String.valueOf(operatorId);
if (safeOperator.length() > 64) {
throw new ServiceException("操作人名称不能超过64个字符");
}
return safeOperator;
}
}

View File

@@ -2,21 +2,31 @@ package com.jsowell.pile.service.impl;
import com.google.common.collect.Lists;
import com.jsowell.common.core.domain.vo.AuthorizedDeptVO;
import com.jsowell.common.util.StringUtils;
import com.jsowell.pile.domain.OrderInvoiceRecord;
import com.jsowell.pile.domain.PileMerchantInfo;
import com.jsowell.pile.dto.GetInvoiceInfoDTO;
import com.jsowell.pile.dto.QueryInvoiceRecordDTO;
import com.jsowell.pile.mapper.OrderInvoiceRecordMapper;
import com.jsowell.pile.service.MemberInvoiceTitleService;
import com.jsowell.pile.service.OrderInvoiceRecordService;
import com.jsowell.pile.service.OrderBasicInfoService;
import com.jsowell.pile.service.PileMerchantInfoService;
import com.jsowell.pile.util.UserUtils;
import com.jsowell.pile.vo.base.OrderAmountDetailVO;
import com.jsowell.pile.vo.uniapp.customer.InvoiceTitleVO;
import com.jsowell.pile.vo.web.InvoiceRecordVO;
import com.jsowell.pile.vo.web.OrderInvoiceRecordVO;
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.time.LocalDateTime;
import java.util.ArrayList;
@@ -30,6 +40,11 @@ import java.util.List;
*/
@Service
public class OrderInvoiceRecordServiceImpl implements OrderInvoiceRecordService {
private static final Logger log = LoggerFactory.getLogger(OrderInvoiceRecordServiceImpl.class);
private static final String TODO_TASK_TYPE = "INVOICE_REVIEW";
private static final String TODO_BUSINESS_TYPE = "ORDER_INVOICE_RECORD";
private static final String TODO_ROUTE_NAME = "invoiceDetail";
@Autowired
private OrderInvoiceRecordMapper orderInvoiceRecordMapper;
@@ -39,6 +54,15 @@ public class OrderInvoiceRecordServiceImpl implements OrderInvoiceRecordService
@Autowired
private OrderBasicInfoService orderBasicInfoService;
@Autowired
private PileMerchantInfoService pileMerchantInfoService;
@Autowired
private TodoTaskAssigneeService todoTaskAssigneeService;
@Autowired
private TodoTaskService todoTaskService;
/**
* 查询申请开票
*
@@ -132,8 +156,13 @@ public class OrderInvoiceRecordServiceImpl implements OrderInvoiceRecordService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int insertOrderInvoiceRecord(OrderInvoiceRecord orderInvoiceRecord) {
return orderInvoiceRecordMapper.insertOrderInvoiceRecord(orderInvoiceRecord);
int rows = orderInvoiceRecordMapper.insertOrderInvoiceRecord(orderInvoiceRecord);
if (rows > 0) {
createInvoiceReviewTodos(orderInvoiceRecord);
}
return rows;
}
/**
@@ -143,8 +172,14 @@ public class OrderInvoiceRecordServiceImpl implements OrderInvoiceRecordService
* @return 结果
*/
@Override
public int updateOrderInvoiceRecord(OrderInvoiceRecord orderInvoiceRecord) {
return orderInvoiceRecordMapper.updateOrderInvoiceRecord(orderInvoiceRecord);
@Transactional(rollbackFor = Exception.class)
public int updateOrderInvoiceRecord(OrderInvoiceRecord orderInvoiceRecord, Long operatorId, String operatorName) {
int rows = orderInvoiceRecordMapper.updateOrderInvoiceRecord(orderInvoiceRecord);
if (rows > 0 && "1".equals(orderInvoiceRecord.getStatus())) {
todoTaskService.completeByBusiness(TODO_BUSINESS_TYPE, String.valueOf(orderInvoiceRecord.getId()),
null, operatorId, operatorName);
}
return rows;
}
/**
@@ -154,8 +189,18 @@ public class OrderInvoiceRecordServiceImpl implements OrderInvoiceRecordService
* @return 结果
*/
@Override
public int deleteOrderInvoiceRecordByIds(Integer[] ids) {
return orderInvoiceRecordMapper.deleteOrderInvoiceRecordByIds(ids);
@Transactional(rollbackFor = Exception.class)
public int deleteOrderInvoiceRecordByIds(Integer[] ids, String operatorName) {
int rows = orderInvoiceRecordMapper.deleteOrderInvoiceRecordByIds(ids);
if (rows > 0 && ids != null) {
for (Integer id : ids) {
if (id != null) {
todoTaskService.cancelByBusiness(TODO_BUSINESS_TYPE, String.valueOf(id), null,
operatorName, "开票申请已关闭");
}
}
}
return rows;
}
/**
@@ -165,7 +210,71 @@ public class OrderInvoiceRecordServiceImpl implements OrderInvoiceRecordService
* @return 结果
*/
@Override
public int deleteOrderInvoiceRecordById(Integer id) {
return orderInvoiceRecordMapper.deleteOrderInvoiceRecordById(id);
@Transactional(rollbackFor = Exception.class)
public int deleteOrderInvoiceRecordById(Integer id, String operatorName) {
int rows = orderInvoiceRecordMapper.deleteOrderInvoiceRecordById(id);
if (rows > 0) {
todoTaskService.cancelByBusiness(TODO_BUSINESS_TYPE, String.valueOf(id), null,
operatorName, "开票申请已关闭");
}
return rows;
}
private void createInvoiceReviewTodos(OrderInvoiceRecord invoiceRecord) {
if (invoiceRecord.getId() == null || StringUtils.isBlank(invoiceRecord.getMerchantId())) {
log.warn("开票申请缺少主键或运营商无法创建待办invoiceId={}, merchantId={}",
invoiceRecord.getId(), invoiceRecord.getMerchantId());
return;
}
PileMerchantInfo merchantInfo;
try {
merchantInfo = pileMerchantInfoService.selectPileMerchantInfoById(Long.parseLong(invoiceRecord.getMerchantId()));
} catch (NumberFormatException exception) {
log.warn("开票申请运营商 ID 非法无法创建待办invoiceId={}, merchantId={}",
invoiceRecord.getId(), invoiceRecord.getMerchantId());
return;
}
if (merchantInfo == null || StringUtils.isBlank(merchantInfo.getDeptId())) {
log.warn("开票申请运营商未配置部门无法创建待办invoiceId={}, merchantId={}",
invoiceRecord.getId(), invoiceRecord.getMerchantId());
return;
}
Long merchantDeptId;
try {
merchantDeptId = Long.parseLong(merchantInfo.getDeptId());
} catch (NumberFormatException exception) {
log.warn("开票申请运营商部门 ID 非法无法创建待办invoiceId={}, deptId={}",
invoiceRecord.getId(), merchantInfo.getDeptId());
return;
}
List<Long> assigneeUserIds = todoTaskAssigneeService.findActiveOperatorAdminUserIdsByDeptTree(merchantDeptId);
if (CollectionUtils.isEmpty(assigneeUserIds)) {
log.warn("开票申请运营商部门下没有正常管理员无法创建待办invoiceId={}, deptId={}",
invoiceRecord.getId(), merchantInfo.getDeptId());
return;
}
for (Long assigneeUserId : assigneeUserIds) {
String idempotentKey = TODO_TASK_TYPE + ":" + TODO_BUSINESS_TYPE + ":"
+ invoiceRecord.getId() + ":" + assigneeUserId;
todoTaskService.createTask(TodoTaskCreateCommand.builder()
.taskType(TODO_TASK_TYPE)
.title("充电订单开票申请")
.summary("有新的充电订单开票申请需要处理,申请单号:" + invoiceRecord.getId())
.businessType(TODO_BUSINESS_TYPE)
.businessId(String.valueOf(invoiceRecord.getId()))
.routeName(TODO_ROUTE_NAME)
.routeParams("{\"params\":{\"id\":\"" + invoiceRecord.getId() + "\"}}")
.assigneeUserId(assigneeUserId)
.assigneeMerchantId(invoiceRecord.getMerchantId())
.priority(TodoTaskConstants.PRIORITY_IMPORTANT)
.idempotentKey(idempotentKey)
.createBy("invoice-system")
.build());
}
}
}

View File

@@ -0,0 +1,41 @@
package com.jsowell.pile.vo.web;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 运营商提现开票详情。
*/
@Data
public class MerchantWithdrawInvoiceVO {
private Long id;
private String withdrawCode;
private String merchantId;
private String invoiceStatus;
private String invoiceNumber;
private String voucherUrl;
private Long submittedBy;
private String submittedName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date submittedTime;
private String closedName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date closedTime;
private String closeReason;
private BigDecimal withdrawAmt;
private BigDecimal feeAmt;
private BigDecimal creditedAmt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date applicationTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date arrivalTime;
}

View File

@@ -0,0 +1,76 @@
<?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.jsowell.pile.mapper.ClearingWithdrawInvoiceMapper">
<resultMap id="BaseResultMap" type="com.jsowell.pile.domain.ClearingWithdrawInvoice">
<id column="id" property="id" />
<result column="withdraw_code" property="withdrawCode" />
<result column="merchant_id" property="merchantId" />
<result column="invoice_status" property="invoiceStatus" />
<result column="invoice_number" property="invoiceNumber" />
<result column="voucher_url" property="voucherUrl" />
<result column="submitted_by" property="submittedBy" />
<result column="submitted_name" property="submittedName" />
<result column="submitted_time" property="submittedTime" />
<result column="closed_by" property="closedBy" />
<result column="closed_name" property="closedName" />
<result column="closed_time" property="closedTime" />
<result column="close_reason" property="closeReason" />
<result column="create_by" property="createBy" />
<result column="create_time" property="createTime" />
<result column="update_by" property="updateBy" />
<result column="update_time" property="updateTime" />
<result column="remark" property="remark" />
<result column="del_flag" property="delFlag" />
</resultMap>
<sql id="BaseColumnList">
id, withdraw_code, merchant_id, invoice_status, invoice_number, voucher_url,
submitted_by, submitted_name, submitted_time, closed_by, closed_name, closed_time,
close_reason, create_by, create_time, update_by, update_time, remark, del_flag
</sql>
<select id="selectByWithdrawCode" resultMap="BaseResultMap">
select <include refid="BaseColumnList" />
from clearing_withdraw_invoice
where withdraw_code = #{withdrawCode}
and del_flag = '0'
limit 1
</select>
<insert id="insertSelective" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
insert into clearing_withdraw_invoice (
withdraw_code, merchant_id, invoice_status, create_by, create_time, del_flag
) values (
#{withdrawCode}, #{merchantId}, #{invoiceStatus}, #{createBy}, sysdate(), #{delFlag}
)
</insert>
<update id="markSubmitted">
update clearing_withdraw_invoice
set invoice_status = #{invoiceStatus},
invoice_number = #{invoiceNumber},
voucher_url = #{voucherUrl},
submitted_by = #{submittedBy},
submitted_name = #{submittedName},
submitted_time = #{submittedTime},
update_by = #{updateBy},
update_time = #{updateTime}
where id = #{id}
and invoice_status = 'PENDING'
and del_flag = '0'
</update>
<update id="markClosed">
update clearing_withdraw_invoice
set invoice_status = #{invoiceStatus},
closed_by = #{closedBy},
closed_name = #{closedName},
closed_time = #{closedTime},
close_reason = #{closeReason},
update_by = #{updateBy},
update_time = #{updateTime}
where id = #{id}
and invoice_status = 'PENDING'
and del_flag = '0'
</update>
</mapper>

View File

@@ -0,0 +1,25 @@
package com.jsowell.quartz.task;
import com.jsowell.system.domain.dto.TodoTaskMaintenanceResult;
import com.jsowell.system.service.TodoTaskMaintenanceService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 待办过期和历史归档定时任务。
*/
@Component("todoTaskMaintenanceTask")
public class TodoTaskMaintenanceTask {
private static final Logger log = LoggerFactory.getLogger(TodoTaskMaintenanceTask.class);
@Autowired
private TodoTaskMaintenanceService todoTaskMaintenanceService;
public void maintain() {
TodoTaskMaintenanceResult result = todoTaskMaintenanceService.maintain();
log.info("待办维护任务完成expiredCount={}, archivedCount={}, archiveBefore={}",
result.getExpiredCount(), result.getArchivedCount(), result.getArchiveBefore());
}
}

View File

@@ -0,0 +1,46 @@
package com.jsowell.system.constant;
/**
* 待办任务常量。
*
* @author jsowell
*/
public final class TodoTaskConstants {
private TodoTaskConstants() {
}
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_PROCESSING = "PROCESSING";
public static final String STATUS_COMPLETED = "COMPLETED";
public static final String STATUS_CANCELLED = "CANCELLED";
public static final String STATUS_EXPIRED = "EXPIRED";
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String READ_UNREAD = "0";
public static final String READ_READ = "1";
public static final int PRIORITY_NORMAL = 1;
public static final int PRIORITY_IMPORTANT = 2;
public static final int PRIORITY_URGENT = 3;
public static final int DEFAULT_HOME_LIMIT = 3;
public static final int MAX_HOME_LIMIT = 10;
/** 现有运营商创建流程使用的运营商管理员角色 ID正式环境可通过配置覆盖。 */
public static final long DEFAULT_OPERATOR_ADMIN_ROLE_ID = 3L;
public static boolean isActiveStatus(String status) {
return STATUS_PENDING.equals(status) || STATUS_PROCESSING.equals(status);
}
public static boolean isTerminalStatus(String status) {
return STATUS_COMPLETED.equals(status)
|| STATUS_CANCELLED.equals(status)
|| STATUS_EXPIRED.equals(status);
}
public static boolean isSupportedStatus(String status) {
return STATUS_ACTIVE.equals(status)
|| isActiveStatus(status)
|| isTerminalStatus(status);
}
}

View File

@@ -0,0 +1,47 @@
package com.jsowell.system.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.jsowell.common.core.domain.BaseEntity;
import lombok.Data;
import java.util.Date;
/**
* 用户待办任务表 sys_todo_task。
*
* @author jsowell
*/
@Data
public class SysTodoTask extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long todoId;
private String taskType;
private String title;
private String summary;
private String content;
private String businessType;
private String businessId;
private String routeName;
private String routeParams;
private Long assigneeUserId;
private String assigneeMerchantId;
private Integer priority;
private String taskStatus;
private String readStatus;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date readTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date dueTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date completedTime;
private Long completedBy;
private String cancelReason;
private String idempotentKey;
private String activeIdempotentKey;
private String delFlag;
}

View File

@@ -0,0 +1,69 @@
package com.jsowell.system.domain.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.io.Serializable;
import java.util.Date;
/**
* 管理端人工创建待办请求。
*/
@Data
public class TodoTaskAdminCreateRequest implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "待办类型不能为空")
@Size(max = 64, message = "待办类型不能超过64个字符")
private String taskType;
@NotBlank(message = "待办标题不能为空")
@Size(max = 200, message = "待办标题不能超过200个字符")
private String title;
@Size(max = 500, message = "待办摘要不能超过500个字符")
private String summary;
@Size(max = 20000, message = "待办内容不能超过20000个字符")
private String content;
@NotBlank(message = "业务类型不能为空")
@Size(max = 64, message = "业务类型不能超过64个字符")
private String businessType;
@NotBlank(message = "业务主键不能为空")
@Size(max = 64, message = "业务主键不能超过64个字符")
private String businessId;
@NotBlank(message = "业务路由不能为空")
@Size(max = 100, message = "业务路由不能超过100个字符")
private String routeName;
@Size(max = 1000, message = "路由参数不能超过1000个字符")
private String routeParams;
@NotNull(message = "接收用户不能为空")
@Min(value = 1, message = "接收用户不能为空")
private Long assigneeUserId;
@Size(max = 64, message = "运营商 ID 不能超过64个字符")
private String assigneeMerchantId;
@Min(value = 1, message = "优先级只能为1、2或3")
@Max(value = 3, message = "优先级只能为1、2或3")
private Integer priority;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date dueTime;
@Size(max = 200, message = "幂等键不能超过200个字符")
private String idempotentKey;
@Size(max = 500, message = "备注不能超过500个字符")
private String remark;
}

View File

@@ -0,0 +1,21 @@
package com.jsowell.system.domain.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 管理端待办查询条件。
*/
@Data
public class TodoTaskAdminQuery implements Serializable {
private static final long serialVersionUID = 1L;
private Long assigneeUserId;
private String assigneeMerchantId;
private String taskStatus;
private String readStatus;
private String taskType;
private String keyword;
private Boolean hideCompleted;
}

View File

@@ -0,0 +1,23 @@
package com.jsowell.system.domain.dto;
import lombok.Data;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.io.Serializable;
/**
* 管理端待办分配请求。
*/
@Data
public class TodoTaskAssignRequest implements Serializable {
private static final long serialVersionUID = 1L;
@NotNull(message = "接收用户不能为空")
@Min(value = 1, message = "接收用户不能为空")
private Long assigneeUserId;
@Size(max = 64, message = "运营商 ID 不能超过64个字符")
private String assigneeMerchantId;
}

View File

@@ -0,0 +1,21 @@
package com.jsowell.system.domain.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.io.Serializable;
/**
* 取消待办请求。
*
* @author jsowell
*/
@Data
public class TodoTaskCancelRequest implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "取消原因不能为空")
@Size(max = 500, message = "取消原因不能超过500个字符")
private String reason;
}

View File

@@ -0,0 +1,38 @@
package com.jsowell.system.domain.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* 创建待办任务命令。
*
* @author jsowell
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TodoTaskCreateCommand implements Serializable {
private static final long serialVersionUID = 1L;
private String taskType;
private String title;
private String summary;
private String content;
private String businessType;
private String businessId;
private String routeName;
private String routeParams;
private Long assigneeUserId;
private String assigneeMerchantId;
private Integer priority;
private Date dueTime;
private String idempotentKey;
private String createBy;
private String remark;
}

View File

@@ -0,0 +1,20 @@
package com.jsowell.system.domain.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 待办维护任务执行结果。
*/
@Data
@AllArgsConstructor
public class TodoTaskMaintenanceResult implements Serializable {
private static final long serialVersionUID = 1L;
private int expiredCount;
private int archivedCount;
private Date archiveBefore;
}

View File

@@ -0,0 +1,23 @@
package com.jsowell.system.domain.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 当前用户待办查询条件。
*
* @author jsowell
*/
@Data
public class TodoTaskQuery implements Serializable {
private static final long serialVersionUID = 1L;
/** 由服务端覆盖,不信任请求参数。 */
private Long assigneeUserId;
private String taskStatus;
private String readStatus;
private String taskType;
private String keyword;
private Boolean hideCompleted;
}

View File

@@ -0,0 +1,82 @@
package com.jsowell.system.mapper;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskAdminQuery;
import com.jsowell.system.domain.dto.TodoTaskQuery;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Date;
/**
* 待办任务数据层。
*
* @author jsowell
*/
public interface SysTodoTaskMapper {
SysTodoTask selectTodoById(@Param("todoId") Long todoId);
SysTodoTask selectTodoByIdForAssignee(@Param("todoId") Long todoId,
@Param("assigneeUserId") Long assigneeUserId);
SysTodoTask selectActiveByIdempotentKey(@Param("activeIdempotentKey") String activeIdempotentKey);
List<SysTodoTask> selectTodoList(TodoTaskQuery query);
List<SysTodoTask> selectAdminTodoList(TodoTaskAdminQuery query);
List<SysTodoTask> selectHomeList(@Param("assigneeUserId") Long assigneeUserId,
@Param("limit") int limit);
int countActive(@Param("assigneeUserId") Long assigneeUserId);
int countUnread(@Param("assigneeUserId") Long assigneeUserId);
int insertTodoTask(SysTodoTask todoTask);
int markTodoRead(@Param("todoId") Long todoId,
@Param("assigneeUserId") Long assigneeUserId,
@Param("updateBy") String updateBy);
int markAllRead(@Param("assigneeUserId") Long assigneeUserId,
@Param("updateBy") String updateBy);
int completeTodoForAssignee(@Param("todoId") Long todoId,
@Param("assigneeUserId") Long assigneeUserId,
@Param("completedBy") Long completedBy,
@Param("updateBy") String updateBy);
int completeTodoByBusiness(@Param("businessType") String businessType,
@Param("businessId") String businessId,
@Param("assigneeUserId") Long assigneeUserId,
@Param("completedBy") Long completedBy,
@Param("updateBy") String updateBy);
int cancelTodoForAssignee(@Param("todoId") Long todoId,
@Param("assigneeUserId") Long assigneeUserId,
@Param("reason") String reason,
@Param("updateBy") String updateBy);
int cancelTodo(@Param("todoId") Long todoId,
@Param("reason") String reason,
@Param("updateBy") String updateBy);
int cancelTodoByBusiness(@Param("businessType") String businessType,
@Param("businessId") String businessId,
@Param("assigneeUserId") Long assigneeUserId,
@Param("reason") String reason,
@Param("updateBy") String updateBy);
int assignTodo(@Param("todoId") Long todoId,
@Param("assigneeUserId") Long assigneeUserId,
@Param("assigneeMerchantId") String assigneeMerchantId,
@Param("activeIdempotentKey") String activeIdempotentKey,
@Param("updateBy") String updateBy);
int hideTodo(@Param("todoId") Long todoId, @Param("updateBy") String updateBy);
int expireDueTasks(@Param("now") Date now, @Param("updateBy") String updateBy);
int archiveTerminalTasks(@Param("archiveBefore") Date archiveBefore,
@Param("updateBy") String updateBy);
}

View File

@@ -19,6 +19,24 @@ public interface SysUserMapper {
*/
List<SysUser> selectUserList(SysUser sysUser);
/**
* 查询部门及下级部门的正常用户 ID。
*
* @param deptId 部门 ID
* @return 用户 ID 集合
*/
List<Long> selectActiveUserIdsByDeptTree(@Param("deptId") Long deptId);
/**
* 查询部门及下级部门中拥有指定角色的正常用户 ID。
*
* @param deptId 部门 ID
* @param roleId 角色 ID
* @return 用户 ID 集合
*/
List<Long> selectActiveUserIdsByDeptTreeAndRole(@Param("deptId") Long deptId,
@Param("roleId") Long roleId);
/**
* 根据条件分页查询已配用户角色列表
*

View File

@@ -0,0 +1,23 @@
package com.jsowell.system.service;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskAdminCreateRequest;
import com.jsowell.system.domain.dto.TodoTaskAdminQuery;
import com.jsowell.system.domain.dto.TodoTaskAssignRequest;
import java.util.List;
/**
* 待办管理端服务。
*/
public interface TodoTaskAdminService {
List<SysTodoTask> list(TodoTaskAdminQuery query);
SysTodoTask create(TodoTaskAdminCreateRequest request, String operatorName);
boolean assign(Long todoId, TodoTaskAssignRequest request, String operatorName);
boolean cancel(Long todoId, Long operatorId, String operatorName, String reason);
boolean hide(Long todoId, String operatorName);
}

View File

@@ -0,0 +1,14 @@
package com.jsowell.system.service;
import java.util.List;
/**
* 待办接收人解析服务。
*
* @author jsowell
*/
public interface TodoTaskAssigneeService {
List<Long> findActiveUserIdsByDeptTree(Long deptId);
List<Long> findActiveOperatorAdminUserIdsByDeptTree(Long deptId);
}

View File

@@ -0,0 +1,10 @@
package com.jsowell.system.service;
import com.jsowell.system.domain.dto.TodoTaskMaintenanceResult;
/**
* 待办过期和历史归档维护服务。
*/
public interface TodoTaskMaintenanceService {
TodoTaskMaintenanceResult maintain();
}

View File

@@ -0,0 +1,42 @@
package com.jsowell.system.service;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskCreateCommand;
import com.jsowell.system.domain.dto.TodoTaskQuery;
import java.util.List;
/**
* 统一待办任务服务。
*
* @author jsowell
*/
public interface TodoTaskService {
SysTodoTask createTask(TodoTaskCreateCommand command);
SysTodoTask getForAssignee(Long todoId, Long assigneeUserId);
List<SysTodoTask> listForAssignee(TodoTaskQuery query, Long assigneeUserId);
List<SysTodoTask> homeList(Long assigneeUserId, Integer limit);
int countActive(Long assigneeUserId);
int countUnread(Long assigneeUserId);
boolean markRead(Long todoId, Long assigneeUserId, String operatorName);
int markAllRead(Long assigneeUserId, String operatorName);
boolean complete(Long todoId, Long assigneeUserId, String operatorName);
int completeByBusiness(String businessType, String businessId, Long assigneeUserId,
Long operatorId, String operatorName);
boolean cancelForAssignee(Long todoId, Long assigneeUserId, String operatorName, String reason);
boolean cancel(Long todoId, Long operatorId, String operatorName, String reason);
int cancelByBusiness(String businessType, String businessId, Long assigneeUserId,
String operatorName, String reason);
}

View File

@@ -0,0 +1,213 @@
package com.jsowell.system.service.impl;
import com.jsowell.common.constant.Constants;
import com.jsowell.common.core.domain.entity.SysUser;
import com.jsowell.common.exception.ServiceException;
import com.jsowell.common.util.StringUtils;
import com.jsowell.system.constant.TodoTaskConstants;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskAdminCreateRequest;
import com.jsowell.system.domain.dto.TodoTaskAdminQuery;
import com.jsowell.system.domain.dto.TodoTaskAssignRequest;
import com.jsowell.system.domain.dto.TodoTaskCreateCommand;
import com.jsowell.system.mapper.SysTodoTaskMapper;
import com.jsowell.system.mapper.SysUserMapper;
import com.jsowell.system.service.TodoTaskAdminService;
import com.jsowell.system.service.TodoTaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
/**
* 待办管理端服务实现。
*/
@Service
public class TodoTaskAdminServiceImpl implements TodoTaskAdminService {
@Autowired
private SysTodoTaskMapper todoTaskMapper;
@Autowired
private SysUserMapper userMapper;
@Autowired
private TodoTaskService todoTaskService;
@Override
public List<SysTodoTask> list(TodoTaskAdminQuery query) {
TodoTaskAdminQuery safeQuery = query == null ? new TodoTaskAdminQuery() : query;
validateQuery(safeQuery);
return todoTaskMapper.selectAdminTodoList(safeQuery);
}
@Override
@Transactional(rollbackFor = Exception.class)
public SysTodoTask create(TodoTaskAdminCreateRequest request, String operatorName) {
if (request == null) {
throw new ServiceException("待办创建参数不能为空");
}
validateActiveUser(request.getAssigneeUserId());
String idempotentKey = StringUtils.isNotBlank(request.getIdempotentKey())
? request.getIdempotentKey().trim()
: buildAssignmentKey(request.getTaskType(), request.getBusinessType(),
request.getBusinessId(), request.getAssigneeUserId());
return todoTaskService.createTask(TodoTaskCreateCommand.builder()
.taskType(request.getTaskType())
.title(request.getTitle())
.summary(request.getSummary())
.content(request.getContent())
.businessType(request.getBusinessType())
.businessId(request.getBusinessId())
.routeName(request.getRouteName())
.routeParams(request.getRouteParams())
.assigneeUserId(request.getAssigneeUserId())
.assigneeMerchantId(request.getAssigneeMerchantId())
.priority(request.getPriority())
.dueTime(request.getDueTime())
.idempotentKey(idempotentKey)
.createBy(normalizeOperator(operatorName))
.remark(request.getRemark())
.build());
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean assign(Long todoId, TodoTaskAssignRequest request, String operatorName) {
if (request == null) {
throw new ServiceException("待办分配参数不能为空");
}
validateTodoId(todoId);
validateActiveUser(request.getAssigneeUserId());
SysTodoTask todoTask = requireTask(todoId);
if (!TodoTaskConstants.isActiveStatus(todoTask.getTaskStatus())) {
throw new ServiceException("只有待处理任务可以重新分配");
}
String activeIdempotentKey = buildAssignmentKey(todoTask.getTaskType(),
todoTask.getBusinessType(), todoTask.getBusinessId(), request.getAssigneeUserId());
String assigneeMerchantId = StringUtils.isBlank(request.getAssigneeMerchantId())
? todoTask.getAssigneeMerchantId()
: normalizeOptional(request.getAssigneeMerchantId(), 64);
try {
int rows = todoTaskMapper.assignTodo(todoId, request.getAssigneeUserId(), assigneeMerchantId,
activeIdempotentKey,
normalizeOperator(operatorName));
if (rows > 0) {
return true;
}
} catch (DuplicateKeyException exception) {
throw new ServiceException("目标用户已存在同一业务的有效待办");
}
throw new ServiceException("当前待办状态不允许重新分配");
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean cancel(Long todoId, Long operatorId, String operatorName, String reason) {
return todoTaskService.cancel(todoId, operatorId, operatorName, reason);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean hide(Long todoId, String operatorName) {
validateTodoId(todoId);
SysTodoTask todoTask = requireTask(todoId);
if (!TodoTaskConstants.isTerminalStatus(todoTask.getTaskStatus())) {
throw new ServiceException("待处理任务需先取消后才能隐藏");
}
if (todoTaskMapper.hideTodo(todoId, normalizeOperator(operatorName)) > 0) {
return true;
}
throw new ServiceException("当前待办状态不允许隐藏");
}
private void validateQuery(TodoTaskAdminQuery query) {
if (query.getAssigneeUserId() != null && query.getAssigneeUserId() <= 0) {
throw new ServiceException("接收用户 ID 不合法");
}
if (StringUtils.isNotEmpty(query.getTaskStatus())
&& !TodoTaskConstants.isSupportedStatus(query.getTaskStatus())) {
throw new ServiceException("不支持的待办状态筛选条件");
}
if (StringUtils.isNotEmpty(query.getReadStatus())
&& !TodoTaskConstants.READ_UNREAD.equals(query.getReadStatus())
&& !TodoTaskConstants.READ_READ.equals(query.getReadStatus())) {
throw new ServiceException("不支持的阅读状态筛选条件");
}
query.setAssigneeMerchantId(normalizeOptional(query.getAssigneeMerchantId(), 64));
query.setTaskType(normalizeOptional(query.getTaskType(), 64));
query.setKeyword(normalizeOptional(query.getKeyword(), 100));
}
private SysTodoTask requireTask(Long todoId) {
SysTodoTask todoTask = todoTaskMapper.selectTodoById(todoId);
if (todoTask == null) {
throw new ServiceException("待办任务不存在");
}
return todoTask;
}
private void validateActiveUser(Long userId) {
if (userId == null || userId <= 0) {
throw new ServiceException("接收用户不能为空");
}
SysUser user = userMapper.selectUserById(userId);
if (user == null || !Constants.ZERO.equals(user.getStatus())
|| !Constants.ZERO.equals(user.getDelFlag())) {
throw new ServiceException("接收用户不存在或已停用");
}
}
private void validateTodoId(Long todoId) {
if (todoId == null || todoId <= 0) {
throw new ServiceException("待办任务 ID 不能为空");
}
}
private String buildAssignmentKey(String taskType, String businessType,
String businessId, Long assigneeUserId) {
String source = StringUtils.trim(taskType) + ":" + StringUtils.trim(businessType)
+ ":" + StringUtils.trim(businessId) + ":" + assigneeUserId;
if (source.length() <= 200) {
return source;
}
return "ADMIN_ASSIGN:" + sha256(source);
}
private String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder(bytes.length * 2);
for (byte item : bytes) {
result.append(String.format("%02x", item & 0xff));
}
return result.toString();
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("当前运行环境不支持 SHA-256", exception);
}
}
private String normalizeOptional(String value, int maxLength) {
if (StringUtils.isEmpty(value)) {
return null;
}
String safeValue = value.trim();
if (safeValue.length() > maxLength) {
throw new ServiceException("查询条件长度不能超过" + maxLength + "个字符");
}
return safeValue;
}
private String normalizeOperator(String operatorName) {
String safeOperator = StringUtils.isNotEmpty(operatorName) ? operatorName.trim() : "system";
if (safeOperator.length() > 64) {
throw new ServiceException("操作人名称不能超过64个字符");
}
return safeOperator;
}
}

View File

@@ -0,0 +1,43 @@
package com.jsowell.system.service.impl;
import com.jsowell.common.exception.ServiceException;
import com.jsowell.system.constant.TodoTaskConstants;
import com.jsowell.system.mapper.SysUserMapper;
import com.jsowell.system.service.TodoTaskAssigneeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 待办接收人解析服务实现。
*
* @author jsowell
*/
@Service
public class TodoTaskAssigneeServiceImpl implements TodoTaskAssigneeService {
@Value("${todo.operator-admin-role-id:3}")
private Long operatorAdminRoleId;
@Autowired
private SysUserMapper userMapper;
@Override
public List<Long> findActiveUserIdsByDeptTree(Long deptId) {
if (deptId == null || deptId <= 0) {
throw new ServiceException("待办接收部门不能为空");
}
return userMapper.selectActiveUserIdsByDeptTree(deptId);
}
@Override
public List<Long> findActiveOperatorAdminUserIdsByDeptTree(Long deptId) {
if (deptId == null || deptId <= 0) {
throw new ServiceException("待办接收部门不能为空");
}
Long roleId = operatorAdminRoleId == null || operatorAdminRoleId <= 0
? TodoTaskConstants.DEFAULT_OPERATOR_ADMIN_ROLE_ID : operatorAdminRoleId;
return userMapper.selectActiveUserIdsByDeptTreeAndRole(deptId, roleId);
}
}

View File

@@ -0,0 +1,38 @@
package com.jsowell.system.service.impl;
import com.jsowell.common.util.DateUtils;
import com.jsowell.system.domain.dto.TodoTaskMaintenanceResult;
import com.jsowell.system.mapper.SysTodoTaskMapper;
import com.jsowell.system.service.TodoTaskMaintenanceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
/**
* 待办过期和历史归档维护服务实现。
*/
@Service
public class TodoTaskMaintenanceServiceImpl implements TodoTaskMaintenanceService {
private static final int DEFAULT_RETENTION_DAYS = 180;
@Value("${todo.retention-days:180}")
private Integer retentionDays;
@Autowired
private SysTodoTaskMapper todoTaskMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public TodoTaskMaintenanceResult maintain() {
Date now = DateUtils.getNowDate();
int safeRetentionDays = retentionDays == null || retentionDays < DEFAULT_RETENTION_DAYS
? DEFAULT_RETENTION_DAYS : retentionDays;
int expiredCount = todoTaskMapper.expireDueTasks(now, "todo-maintenance");
Date archiveBefore = DateUtils.addDays(now, -safeRetentionDays);
int archivedCount = todoTaskMapper.archiveTerminalTasks(archiveBefore, "todo-maintenance");
return new TodoTaskMaintenanceResult(expiredCount, archivedCount, archiveBefore);
}
}

View File

@@ -0,0 +1,393 @@
package com.jsowell.system.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jsowell.common.exception.ServiceException;
import com.jsowell.common.util.StringUtils;
import com.jsowell.system.constant.TodoTaskConstants;
import com.jsowell.system.domain.SysTodoTask;
import com.jsowell.system.domain.dto.TodoTaskCreateCommand;
import com.jsowell.system.domain.dto.TodoTaskQuery;
import com.jsowell.system.mapper.SysTodoTaskMapper;
import com.jsowell.system.service.TodoTaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 统一待办任务服务实现。
*
* @author jsowell
*/
@Service
public class TodoTaskServiceImpl implements TodoTaskService {
private static final String SYSTEM_OPERATOR = "system";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final Set<String> DEFAULT_ROUTE_WHITELIST = new HashSet<>(
Arrays.asList("invoiceDetail", "financeDetail"));
@Value("${todo.route-whitelist:invoiceDetail,financeDetail}")
private String routeWhitelist;
@Autowired
private SysTodoTaskMapper todoTaskMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public SysTodoTask createTask(TodoTaskCreateCommand command) {
if (command == null) {
throw new ServiceException("待办创建参数不能为空");
}
SysTodoTask todoTask = buildTask(command);
SysTodoTask existingTask = todoTaskMapper.selectActiveByIdempotentKey(todoTask.getActiveIdempotentKey());
if (existingTask != null) {
return existingTask;
}
try {
todoTaskMapper.insertTodoTask(todoTask);
return todoTask;
} catch (DuplicateKeyException exception) {
existingTask = todoTaskMapper.selectActiveByIdempotentKey(todoTask.getActiveIdempotentKey());
if (existingTask != null) {
return existingTask;
}
throw exception;
}
}
@Override
public SysTodoTask getForAssignee(Long todoId, Long assigneeUserId) {
validateIdentity(todoId, assigneeUserId);
SysTodoTask todoTask = todoTaskMapper.selectTodoByIdForAssignee(todoId, assigneeUserId);
if (todoTask == null) {
throw new ServiceException("待办任务不存在或无权访问");
}
return todoTask;
}
@Override
public List<SysTodoTask> listForAssignee(TodoTaskQuery query, Long assigneeUserId) {
validateUserId(assigneeUserId);
TodoTaskQuery safeQuery = query == null ? new TodoTaskQuery() : query;
validateQuery(safeQuery);
safeQuery.setAssigneeUserId(assigneeUserId);
return todoTaskMapper.selectTodoList(safeQuery);
}
@Override
public List<SysTodoTask> homeList(Long assigneeUserId, Integer limit) {
validateUserId(assigneeUserId);
int safeLimit = limit == null ? TodoTaskConstants.DEFAULT_HOME_LIMIT : limit;
if (safeLimit < 1) {
safeLimit = TodoTaskConstants.DEFAULT_HOME_LIMIT;
}
if (safeLimit > TodoTaskConstants.MAX_HOME_LIMIT) {
safeLimit = TodoTaskConstants.MAX_HOME_LIMIT;
}
return todoTaskMapper.selectHomeList(assigneeUserId, safeLimit);
}
@Override
public int countActive(Long assigneeUserId) {
validateUserId(assigneeUserId);
return todoTaskMapper.countActive(assigneeUserId);
}
@Override
public int countUnread(Long assigneeUserId) {
validateUserId(assigneeUserId);
return todoTaskMapper.countUnread(assigneeUserId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean markRead(Long todoId, Long assigneeUserId, String operatorName) {
validateIdentity(todoId, assigneeUserId);
int rows = todoTaskMapper.markTodoRead(todoId, assigneeUserId, normalizeOperator(operatorName, assigneeUserId));
if (rows > 0) {
return true;
}
SysTodoTask todoTask = getForAssignee(todoId, assigneeUserId);
return TodoTaskConstants.READ_READ.equals(todoTask.getReadStatus());
}
@Override
@Transactional(rollbackFor = Exception.class)
public int markAllRead(Long assigneeUserId, String operatorName) {
validateUserId(assigneeUserId);
return todoTaskMapper.markAllRead(assigneeUserId, normalizeOperator(operatorName, assigneeUserId));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean complete(Long todoId, Long assigneeUserId, String operatorName) {
validateIdentity(todoId, assigneeUserId);
int rows = todoTaskMapper.completeTodoForAssignee(todoId, assigneeUserId, assigneeUserId,
normalizeOperator(operatorName, assigneeUserId));
if (rows > 0) {
return true;
}
SysTodoTask todoTask = getForAssignee(todoId, assigneeUserId);
if (TodoTaskConstants.STATUS_COMPLETED.equals(todoTask.getTaskStatus())) {
return true;
}
throw new ServiceException("当前待办状态不允许完成");
}
@Override
@Transactional(rollbackFor = Exception.class)
public int completeByBusiness(String businessType, String businessId, Long assigneeUserId,
Long operatorId, String operatorName) {
String safeBusinessType = normalizeRequired(businessType, "业务类型", 64);
String safeBusinessId = normalizeRequired(businessId, "业务主键", 64);
if (assigneeUserId != null) {
validateUserId(assigneeUserId);
}
if (operatorId == null || operatorId <= 0) {
throw new ServiceException("待办完成人不能为空");
}
return todoTaskMapper.completeTodoByBusiness(safeBusinessType, safeBusinessId, assigneeUserId,
operatorId, normalizeOperator(operatorName, operatorId));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean cancelForAssignee(Long todoId, Long assigneeUserId, String operatorName, String reason) {
validateIdentity(todoId, assigneeUserId);
String safeReason = normalizeRequired(reason, "取消原因", 500);
int rows = todoTaskMapper.cancelTodoForAssignee(todoId, assigneeUserId, safeReason,
normalizeOperator(operatorName, assigneeUserId));
if (rows > 0) {
return true;
}
SysTodoTask todoTask = getForAssignee(todoId, assigneeUserId);
if (TodoTaskConstants.STATUS_CANCELLED.equals(todoTask.getTaskStatus())) {
return true;
}
throw new ServiceException("当前待办状态不允许取消");
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean cancel(Long todoId, Long operatorId, String operatorName, String reason) {
if (todoId == null || todoId <= 0) {
throw new ServiceException("待办任务 ID 不能为空");
}
if (operatorId == null || operatorId <= 0) {
throw new ServiceException("取消操作人不能为空");
}
String safeReason = normalizeRequired(reason, "取消原因", 500);
int rows = todoTaskMapper.cancelTodo(todoId, safeReason, normalizeOperator(operatorName, operatorId));
if (rows > 0) {
return true;
}
SysTodoTask todoTask = todoTaskMapper.selectTodoById(todoId);
if (todoTask == null) {
throw new ServiceException("待办任务不存在");
}
if (TodoTaskConstants.STATUS_CANCELLED.equals(todoTask.getTaskStatus())) {
return true;
}
throw new ServiceException("当前待办状态不允许取消");
}
@Override
@Transactional(rollbackFor = Exception.class)
public int cancelByBusiness(String businessType, String businessId, Long assigneeUserId,
String operatorName, String reason) {
String safeBusinessType = normalizeRequired(businessType, "业务类型", 64);
String safeBusinessId = normalizeRequired(businessId, "业务主键", 64);
if (assigneeUserId != null) {
validateUserId(assigneeUserId);
}
String safeReason = normalizeRequired(reason, "取消原因", 500);
return todoTaskMapper.cancelTodoByBusiness(safeBusinessType, safeBusinessId, assigneeUserId,
safeReason, normalizeOperator(operatorName, null));
}
private SysTodoTask buildTask(TodoTaskCreateCommand command) {
if (command.getAssigneeUserId() == null || command.getAssigneeUserId() <= 0) {
throw new ServiceException("待办接收用户不能为空");
}
Integer priority = command.getPriority() == null
? TodoTaskConstants.PRIORITY_NORMAL : command.getPriority();
if (priority < TodoTaskConstants.PRIORITY_NORMAL || priority > TodoTaskConstants.PRIORITY_URGENT) {
throw new ServiceException("待办优先级只能为 1、2 或 3");
}
SysTodoTask todoTask = new SysTodoTask();
todoTask.setTaskType(normalizeRequired(command.getTaskType(), "待办类型", 64));
todoTask.setTitle(normalizeRequired(command.getTitle(), "待办标题", 200));
todoTask.setSummary(normalizeOptional(command.getSummary(), "待办摘要", 500));
todoTask.setContent(normalizeOptional(command.getContent(), "待办内容", 20000));
todoTask.setBusinessType(normalizeRequired(command.getBusinessType(), "业务类型", 64));
todoTask.setBusinessId(normalizeRequired(command.getBusinessId(), "业务主键", 64));
String routeName = normalizeRequired(command.getRouteName(), "业务路由", 100);
validateRouteName(routeName);
todoTask.setRouteName(routeName);
todoTask.setRouteParams(normalizeRouteParams(command.getRouteParams()));
todoTask.setAssigneeUserId(command.getAssigneeUserId());
todoTask.setAssigneeMerchantId(normalizeOptional(command.getAssigneeMerchantId(), "运营商 ID", 64));
todoTask.setPriority(priority);
todoTask.setTaskStatus(TodoTaskConstants.STATUS_PENDING);
todoTask.setReadStatus(TodoTaskConstants.READ_UNREAD);
todoTask.setDueTime(command.getDueTime());
todoTask.setIdempotentKey(normalizeRequired(command.getIdempotentKey(), "幂等键", 200));
todoTask.setActiveIdempotentKey(todoTask.getIdempotentKey());
todoTask.setCreateBy(normalizeOperator(command.getCreateBy(), null));
todoTask.setRemark(normalizeOptional(command.getRemark(), "备注", 500));
todoTask.setDelFlag("0");
return todoTask;
}
private void validateQuery(TodoTaskQuery query) {
if (StringUtils.isNotEmpty(query.getTaskStatus())
&& !TodoTaskConstants.isSupportedStatus(query.getTaskStatus())) {
throw new ServiceException("不支持的待办状态筛选条件");
}
if (StringUtils.isNotEmpty(query.getReadStatus())
&& !TodoTaskConstants.READ_UNREAD.equals(query.getReadStatus())
&& !TodoTaskConstants.READ_READ.equals(query.getReadStatus())) {
throw new ServiceException("不支持的阅读状态筛选条件");
}
query.setTaskType(normalizeOptional(query.getTaskType(), "待办类型", 64));
query.setKeyword(normalizeOptional(query.getKeyword(), "关键字", 100));
}
private void validateIdentity(Long todoId, Long assigneeUserId) {
if (todoId == null || todoId <= 0) {
throw new ServiceException("待办任务 ID 不能为空");
}
validateUserId(assigneeUserId);
}
private void validateUserId(Long userId) {
if (userId == null || userId <= 0) {
throw new ServiceException("待办接收用户不能为空");
}
}
private String normalizeRequired(String value, String fieldName, int maxLength) {
String safeValue = StringUtils.trim(value);
if (StringUtils.isEmpty(safeValue)) {
throw new ServiceException(fieldName + "不能为空");
}
if (safeValue.length() > maxLength) {
throw new ServiceException(fieldName + "不能超过" + maxLength + "个字符");
}
return safeValue;
}
private String normalizeOptional(String value, String fieldName, int maxLength) {
if (StringUtils.isEmpty(value)) {
return null;
}
String safeValue = value.trim();
if (safeValue.length() > maxLength) {
throw new ServiceException(fieldName + "不能超过" + maxLength + "个字符");
}
return safeValue;
}
private void validateRouteName(String routeName) {
Set<String> allowedRoutes = DEFAULT_ROUTE_WHITELIST;
if (StringUtils.isNotBlank(routeWhitelist)) {
allowedRoutes = new HashSet<>();
for (String configuredRoute : routeWhitelist.split(",")) {
if (StringUtils.isNotBlank(configuredRoute)) {
allowedRoutes.add(configuredRoute.trim());
}
}
}
if (!allowedRoutes.contains(routeName)) {
throw new ServiceException("待办业务路由未加入白名单");
}
}
private String normalizeRouteParams(String routeParams) {
String safeRouteParams = normalizeOptional(routeParams, "路由参数", 1000);
if (safeRouteParams == null) {
return null;
}
try {
JsonNode root = OBJECT_MAPPER.readTree(safeRouteParams);
if (root == null || !root.isObject()) {
throw new ServiceException("待办路由参数必须是 JSON 对象");
}
boolean structured = root.has("params") || root.has("query");
Iterator<Map.Entry<String, JsonNode>> fields = root.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
validateRouteParamKey(field.getKey());
if (structured) {
if (!"params".equals(field.getKey()) && !"query".equals(field.getKey())) {
throw new ServiceException("待办路由参数仅允许 params 和 query");
}
validateRouteParamObject(field.getValue());
} else {
validateRouteParamValue(field.getValue());
}
}
return safeRouteParams;
} catch (ServiceException exception) {
throw exception;
} catch (Exception exception) {
throw new ServiceException("待办路由参数不是合法 JSON");
}
}
private void validateRouteParamObject(JsonNode node) {
if (node == null || !node.isObject()) {
throw new ServiceException("待办路由 params 和 query 必须是 JSON 对象");
}
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
validateRouteParamKey(field.getKey());
validateRouteParamValue(field.getValue());
}
}
private void validateRouteParamKey(String key) {
if (StringUtils.isBlank(key) || key.length() > 64) {
throw new ServiceException("待办路由参数名称不合法");
}
}
private void validateRouteParamValue(JsonNode value) {
if (value == null || value.isNull()) {
return;
}
if (!value.isTextual() && !value.isNumber() && !value.isBoolean()) {
throw new ServiceException("待办路由参数值只能是字符串、数字或布尔值");
}
if (value.isTextual() && value.asText().length() > 500) {
throw new ServiceException("待办路由参数值不能超过500个字符");
}
}
private String normalizeOperator(String operatorName, Long operatorId) {
String safeOperator = StringUtils.isNotEmpty(operatorName)
? operatorName.trim()
: (operatorId == null ? SYSTEM_OPERATOR : String.valueOf(operatorId));
if (safeOperator.length() > 64) {
throw new ServiceException("操作人名称不能超过64个字符");
}
return safeOperator;
}
}

View File

@@ -0,0 +1,314 @@
<?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.jsowell.system.mapper.SysTodoTaskMapper">
<resultMap type="SysTodoTask" id="SysTodoTaskResult">
<id property="todoId" column="todo_id" />
<result property="taskType" column="task_type" />
<result property="title" column="title" />
<result property="summary" column="summary" />
<result property="content" column="content" />
<result property="businessType" column="business_type" />
<result property="businessId" column="business_id" />
<result property="routeName" column="route_name" />
<result property="routeParams" column="route_params" />
<result property="assigneeUserId" column="assignee_user_id" />
<result property="assigneeMerchantId" column="assignee_merchant_id" />
<result property="priority" column="priority" />
<result property="taskStatus" column="task_status" />
<result property="readStatus" column="read_status" />
<result property="readTime" column="read_time" />
<result property="dueTime" column="due_time" />
<result property="completedTime" column="completed_time" />
<result property="completedBy" column="completed_by" />
<result property="cancelReason" column="cancel_reason" />
<result property="idempotentKey" column="idempotent_key" />
<result property="activeIdempotentKey" column="active_idempotent_key" />
<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" />
<result property="delFlag" column="del_flag" />
</resultMap>
<sql id="selectTodoVo">
select todo_id, task_type, title, summary, cast(content as char) as content,
business_type, business_id, route_name, route_params,
assignee_user_id, assignee_merchant_id, priority, task_status,
read_status, read_time, due_time, completed_time, completed_by,
cancel_reason, idempotent_key, active_idempotent_key,
create_by, create_time, update_by, update_time, remark, del_flag
from sys_todo_task
</sql>
<select id="selectTodoById" parameterType="Long" resultMap="SysTodoTaskResult">
<include refid="selectTodoVo" />
where todo_id = #{todoId}
and del_flag = '0'
</select>
<select id="selectTodoByIdForAssignee" resultMap="SysTodoTaskResult">
<include refid="selectTodoVo" />
where todo_id = #{todoId}
and assignee_user_id = #{assigneeUserId}
and del_flag = '0'
</select>
<select id="selectActiveByIdempotentKey" parameterType="String" resultMap="SysTodoTaskResult">
<include refid="selectTodoVo" />
where active_idempotent_key = #{activeIdempotentKey}
and del_flag = '0'
limit 1
</select>
<select id="selectTodoList" parameterType="com.jsowell.system.domain.dto.TodoTaskQuery"
resultMap="SysTodoTaskResult">
<include refid="selectTodoVo" />
<where>
del_flag = '0'
and assignee_user_id = #{assigneeUserId}
<if test="taskStatus != null and taskStatus == 'ACTIVE'">
and task_status in ('PENDING', 'PROCESSING')
</if>
<if test="taskStatus != null and taskStatus != '' and taskStatus != 'ACTIVE'">
and task_status = #{taskStatus}
</if>
<if test="readStatus != null and readStatus != ''">
and read_status = #{readStatus}
</if>
<if test="taskType != null and taskType != ''">
and task_type = #{taskType}
</if>
<if test="keyword != null and keyword != ''">
and (title like concat('%', #{keyword}, '%')
or business_id like concat('%', #{keyword}, '%'))
</if>
<if test="hideCompleted != null and hideCompleted">
and task_status != 'COMPLETED'
</if>
</where>
order by priority desc, create_time desc, todo_id desc
</select>
<select id="selectAdminTodoList" parameterType="com.jsowell.system.domain.dto.TodoTaskAdminQuery"
resultMap="SysTodoTaskResult">
<include refid="selectTodoVo" />
<where>
del_flag = '0'
<if test="assigneeUserId != null">
and assignee_user_id = #{assigneeUserId}
</if>
<if test="assigneeMerchantId != null and assigneeMerchantId != ''">
and assignee_merchant_id = #{assigneeMerchantId}
</if>
<if test="taskStatus != null and taskStatus == 'ACTIVE'">
and task_status in ('PENDING', 'PROCESSING')
</if>
<if test="taskStatus != null and taskStatus != '' and taskStatus != 'ACTIVE'">
and task_status = #{taskStatus}
</if>
<if test="readStatus != null and readStatus != ''">
and read_status = #{readStatus}
</if>
<if test="taskType != null and taskType != ''">
and task_type = #{taskType}
</if>
<if test="keyword != null and keyword != ''">
and (title like concat('%', #{keyword}, '%')
or business_id like concat('%', #{keyword}, '%'))
</if>
<if test="hideCompleted != null and hideCompleted">
and task_status != 'COMPLETED'
</if>
</where>
order by priority desc, create_time desc, todo_id desc
</select>
<select id="selectHomeList" resultMap="SysTodoTaskResult">
<include refid="selectTodoVo" />
where assignee_user_id = #{assigneeUserId}
and task_status in ('PENDING', 'PROCESSING')
and del_flag = '0'
order by priority desc, create_time desc, todo_id desc
limit #{limit}
</select>
<select id="countActive" parameterType="Long" resultType="int">
select count(1)
from sys_todo_task
where assignee_user_id = #{assigneeUserId}
and task_status in ('PENDING', 'PROCESSING')
and del_flag = '0'
</select>
<select id="countUnread" parameterType="Long" resultType="int">
select count(1)
from sys_todo_task
where assignee_user_id = #{assigneeUserId}
and task_status in ('PENDING', 'PROCESSING')
and read_status = '0'
and del_flag = '0'
</select>
<insert id="insertTodoTask" parameterType="SysTodoTask" useGeneratedKeys="true" keyProperty="todoId">
insert into sys_todo_task (
task_type, title, summary, content, business_type, business_id,
route_name, route_params, assignee_user_id, assignee_merchant_id,
priority, task_status, read_status, due_time, idempotent_key,
active_idempotent_key, create_by, create_time, remark, del_flag
) values (
#{taskType}, #{title}, #{summary}, #{content}, #{businessType}, #{businessId},
#{routeName}, #{routeParams}, #{assigneeUserId}, #{assigneeMerchantId},
#{priority}, #{taskStatus}, #{readStatus}, #{dueTime}, #{idempotentKey},
#{activeIdempotentKey}, #{createBy}, sysdate(), #{remark}, #{delFlag}
)
</insert>
<update id="markTodoRead">
update sys_todo_task
set read_status = '1',
read_time = sysdate(),
update_by = #{updateBy},
update_time = sysdate()
where todo_id = #{todoId}
and assignee_user_id = #{assigneeUserId}
and read_status = '0'
and del_flag = '0'
</update>
<update id="markAllRead">
update sys_todo_task
set read_status = '1',
read_time = sysdate(),
update_by = #{updateBy},
update_time = sysdate()
where assignee_user_id = #{assigneeUserId}
and read_status = '0'
and del_flag = '0'
</update>
<update id="completeTodoForAssignee">
update sys_todo_task
set task_status = 'COMPLETED',
completed_time = sysdate(),
completed_by = #{completedBy},
active_idempotent_key = null,
update_by = #{updateBy},
update_time = sysdate()
where todo_id = #{todoId}
and assignee_user_id = #{assigneeUserId}
and task_status in ('PENDING', 'PROCESSING')
and del_flag = '0'
</update>
<update id="completeTodoByBusiness">
update sys_todo_task
set task_status = 'COMPLETED',
completed_time = sysdate(),
completed_by = #{completedBy},
active_idempotent_key = null,
update_by = #{updateBy},
update_time = sysdate()
where business_type = #{businessType}
and business_id = #{businessId}
<if test="assigneeUserId != null">
and assignee_user_id = #{assigneeUserId}
</if>
and task_status in ('PENDING', 'PROCESSING')
and del_flag = '0'
</update>
<update id="cancelTodoForAssignee">
update sys_todo_task
set task_status = 'CANCELLED',
cancel_reason = #{reason},
active_idempotent_key = null,
update_by = #{updateBy},
update_time = sysdate()
where todo_id = #{todoId}
and assignee_user_id = #{assigneeUserId}
and task_status in ('PENDING', 'PROCESSING')
and del_flag = '0'
</update>
<update id="cancelTodo">
update sys_todo_task
set task_status = 'CANCELLED',
cancel_reason = #{reason},
active_idempotent_key = null,
update_by = #{updateBy},
update_time = sysdate()
where todo_id = #{todoId}
and task_status in ('PENDING', 'PROCESSING')
and del_flag = '0'
</update>
<update id="cancelTodoByBusiness">
update sys_todo_task
set task_status = 'CANCELLED',
cancel_reason = #{reason},
active_idempotent_key = null,
update_by = #{updateBy},
update_time = sysdate()
where business_type = #{businessType}
and business_id = #{businessId}
<if test="assigneeUserId != null">
and assignee_user_id = #{assigneeUserId}
</if>
and task_status in ('PENDING', 'PROCESSING')
and del_flag = '0'
</update>
<update id="assignTodo">
update sys_todo_task
set assignee_user_id = #{assigneeUserId},
assignee_merchant_id = #{assigneeMerchantId},
read_status = '0',
read_time = null,
active_idempotent_key = #{activeIdempotentKey},
update_by = #{updateBy},
update_time = sysdate()
where todo_id = #{todoId}
and task_status in ('PENDING', 'PROCESSING')
and del_flag = '0'
</update>
<update id="hideTodo">
update sys_todo_task
set active_idempotent_key = null,
del_flag = '2',
update_by = #{updateBy},
update_time = sysdate()
where todo_id = #{todoId}
and task_status in ('COMPLETED', 'CANCELLED', 'EXPIRED')
and del_flag = '0'
</update>
<update id="expireDueTasks">
update sys_todo_task
set task_status = 'EXPIRED',
completed_time = #{now},
active_idempotent_key = null,
update_by = #{updateBy},
update_time = #{now}
where task_status in ('PENDING', 'PROCESSING')
and due_time is not null
and due_time &lt;= #{now}
and del_flag = '0'
</update>
<update id="archiveTerminalTasks">
update sys_todo_task
set del_flag = '2',
active_idempotent_key = null,
update_by = #{updateBy},
update_time = sysdate()
where task_status in ('COMPLETED', 'CANCELLED', 'EXPIRED')
and coalesce(completed_time, update_time, create_time) &lt; #{archiveBefore}
and del_flag = '0'
</update>
</mapper>

View File

@@ -91,6 +91,45 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
${params.dataScope}
</select>
<select id="selectActiveUserIdsByDeptTree" resultType="Long">
select distinct u.user_id
from sys_user u
where u.status = '0'
and u.del_flag = '0'
and (
u.dept_id = #{deptId}
or u.dept_id in (
select d.dept_id
from sys_dept d
where find_in_set(#{deptId}, d.ancestors)
and d.status = '0'
)
)
order by u.user_id
</select>
<select id="selectActiveUserIdsByDeptTreeAndRole" resultType="Long">
select distinct u.user_id
from sys_user u
inner join sys_user_role ur on ur.user_id = u.user_id
inner join sys_role r on r.role_id = ur.role_id
where u.status = '0'
and u.del_flag = '0'
and r.status = '0'
and r.del_flag = '0'
and ur.role_id = #{roleId}
and (
u.dept_id = #{deptId}
or u.dept_id in (
select d.dept_id
from sys_dept d
where find_in_set(#{deptId}, d.ancestors)
and d.status = '0'
)
)
order by u.user_id
</select>
<select id="selectAllocatedList" parameterType="com.jsowell.common.core.domain.entity.SysUser" resultMap="SysUserResult">
select distinct u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.phone, u.status, u.create_time
from sys_user u