diff --git a/apps/web/src/pages/Workflow.tsx b/apps/web/src/pages/Workflow.tsx index f9efc64..84b2630 100644 --- a/apps/web/src/pages/Workflow.tsx +++ b/apps/web/src/pages/Workflow.tsx @@ -9,7 +9,7 @@ export default function Workflow() { const [activeKey, setActiveKey] = useState('definitions'); return ( -
+
{ + const fetchData = useCallback(async () => { setLoading(true); try { const res = await listCompletedTasks(page, 20); @@ -24,9 +24,9 @@ export default function CompletedTasks() { } finally { setLoading(false); } - }; + }, [page]); - useEffect(() => { fetch(); }, [page]); + useEffect(() => { fetchData(); }, [fetchData]); const columns: ColumnsType = [ { title: '任务名称', dataIndex: 'node_name', key: 'node_name' }, diff --git a/apps/web/src/pages/workflow/InstanceMonitor.tsx b/apps/web/src/pages/workflow/InstanceMonitor.tsx index d4aa2a1..9f16b9d 100644 --- a/apps/web/src/pages/workflow/InstanceMonitor.tsx +++ b/apps/web/src/pages/workflow/InstanceMonitor.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { Button, message, Space, Table, Tag } from 'antd'; +import { useCallback, useEffect, useState } from 'react'; +import { Button, message, Modal, Table, Tag } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { listInstances, @@ -20,7 +20,7 @@ export default function InstanceMonitor() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); - const fetch = async () => { + const fetchData = useCallback(async () => { setLoading(true); try { const res = await listInstances(page, 20); @@ -29,18 +29,27 @@ export default function InstanceMonitor() { } finally { setLoading(false); } - }; + }, [page]); - useEffect(() => { fetch(); }, [page]); + useEffect(() => { fetchData(); }, [fetchData]); const handleTerminate = async (id: string) => { - try { - await terminateInstance(id); - message.success('已终止'); - fetch(); - } catch { - message.error('操作失败'); - } + Modal.confirm({ + title: '确认终止', + content: '确定要终止该流程实例吗?此操作不可撤销。', + okText: '确定终止', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + await terminateInstance(id); + message.success('已终止'); + fetchData(); + } catch { + message.error('操作失败'); + } + }, + }); }; const columns: ColumnsType = [ diff --git a/apps/web/src/pages/workflow/PendingTasks.tsx b/apps/web/src/pages/workflow/PendingTasks.tsx index 34d8efa..61a9a5f 100644 --- a/apps/web/src/pages/workflow/PendingTasks.tsx +++ b/apps/web/src/pages/workflow/PendingTasks.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Button, message, Modal, Space, Table, Tag } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { @@ -19,7 +19,7 @@ export default function PendingTasks() { const [completeModal, setCompleteModal] = useState(null); const [outcome, setOutcome] = useState('approved'); - const fetch = async () => { + const fetchData = useCallback(async () => { setLoading(true); try { const res = await listPendingTasks(page, 20); @@ -28,9 +28,9 @@ export default function PendingTasks() { } finally { setLoading(false); } - }; + }, [page]); - useEffect(() => { fetch(); }, [page]); + useEffect(() => { fetchData(); }, [fetchData]); const handleComplete = async () => { if (!completeModal) return; @@ -38,7 +38,7 @@ export default function PendingTasks() { await completeTask(completeModal.id, { outcome }); message.success('审批完成'); setCompleteModal(null); - fetch(); + fetchData(); } catch { message.error('审批失败'); } diff --git a/apps/web/src/pages/workflow/ProcessDefinitions.tsx b/apps/web/src/pages/workflow/ProcessDefinitions.tsx index 545709b..a918bdd 100644 --- a/apps/web/src/pages/workflow/ProcessDefinitions.tsx +++ b/apps/web/src/pages/workflow/ProcessDefinitions.tsx @@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table'; import { listProcessDefinitions, createProcessDefinition, + updateProcessDefinition, publishProcessDefinition, type ProcessDefinitionInfo, type CreateProcessDefinitionRequest, @@ -57,14 +58,19 @@ export default function ProcessDefinitions() { } }; - const handleSave = async (req: CreateProcessDefinitionRequest) => { + const handleSave = async (req: CreateProcessDefinitionRequest, id?: string) => { try { - await createProcessDefinition(req); - message.success('创建成功'); + if (id) { + await updateProcessDefinition(id, req); + message.success('更新成功'); + } else { + await createProcessDefinition(req); + message.success('创建成功'); + } setDesignerOpen(false); fetch(); } catch { - message.error('创建失败'); + message.error(id ? '更新失败' : '创建失败'); } }; diff --git a/apps/web/src/pages/workflow/ProcessDesigner.tsx b/apps/web/src/pages/workflow/ProcessDesigner.tsx index 3104115..fc4eae6 100644 --- a/apps/web/src/pages/workflow/ProcessDesigner.tsx +++ b/apps/web/src/pages/workflow/ProcessDesigner.tsx @@ -1,5 +1,5 @@ -import { useCallback, useMemo, useState } from 'react'; -import { Button, Form, Input, message, Space } from 'antd'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button, Form, Input, message, Spin } from 'antd'; import { ReactFlow, Controls, @@ -18,6 +18,7 @@ import { type CreateProcessDefinitionRequest, type NodeDef, type EdgeDef, + getProcessDefinition, } from '../../api/workflowDefinitions'; const NODE_TYPES_MAP: Record = { @@ -35,9 +36,9 @@ const PALETTE_ITEMS = Object.entries(NODE_TYPES_MAP).map(([type, info]) => ({ color: info.color, })); -function createFlowNode(type: string, label: string, position: { x: number; y: number }): Node { +function createFlowNode(type: string, label: string, position: { x: number; y: number }, id?: string): Node { return { - id: `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + id: id || `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, type: 'default', position, data: { label: `${label}`, nodeType: type, name: label }, @@ -57,31 +58,57 @@ function createFlowNode(type: string, label: string, position: { x: number; y: n interface ProcessDesignerProps { definitionId: string | null; - onSave: (req: CreateProcessDefinitionRequest) => void; + onSave: (req: CreateProcessDefinitionRequest, id?: string) => void; } -export default function ProcessDesigner({ onSave }: ProcessDesignerProps) { +export default function ProcessDesigner({ definitionId, onSave }: ProcessDesignerProps) { const [form] = Form.useForm(); const [selectedNode, setSelectedNode] = useState(null); - const [nodes, setNodes, onNodesChange] = useNodesState([ - createFlowNode('StartEvent', '开始', { x: 250, y: 50 }), - createFlowNode('UserTask', '审批', { x: 250, y: 200 }), - createFlowNode('EndEvent', '结束', { x: 250, y: 400 }), - ]); - const [edges, setEdges, onEdgesChange] = useEdgesState([ - { - id: 'e_start_approve', - source: nodes[0].id, - target: nodes[1].id, - markerEnd: { type: MarkerType.ArrowClosed }, - }, - { - id: 'e_approve_end', - source: nodes[1].id, - target: nodes[2].id, - markerEnd: { type: MarkerType.ArrowClosed }, - }, - ]); + const [loading, setLoading] = useState(false); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + const isEditing = definitionId !== null; + + // 加载流程定义(编辑模式)或初始化默认节点(新建模式) + useEffect(() => { + if (!definitionId) { + const startNode = createFlowNode('StartEvent', '开始', { x: 250, y: 50 }); + const userNode = createFlowNode('UserTask', '审批', { x: 250, y: 200 }); + const endNode = createFlowNode('EndEvent', '结束', { x: 250, y: 400 }); + setNodes([startNode, userNode, endNode]); + setEdges([ + { id: 'e_start_approve', source: startNode.id, target: userNode.id, markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'e_approve_end', source: userNode.id, target: endNode.id, markerEnd: { type: MarkerType.ArrowClosed } }, + ]); + return; + } + + setLoading(true); + getProcessDefinition(definitionId) + .then((def) => { + form.setFieldsValue({ + name: def.name, + key: def.key, + category: def.category, + description: def.description, + }); + const flowNodes = def.nodes.map((n, i) => + createFlowNode(n.type, n.name, n.position || { x: 200, y: i * 120 + 50 }, n.id) + ); + const flowEdges: Edge[] = def.edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + markerEnd: { type: MarkerType.ArrowClosed }, + label: e.label || e.condition, + })); + setNodes(flowNodes); + setEdges(flowEdges); + }) + .catch(() => message.error('加载流程定义失败')) + .finally(() => setLoading(false)); + }, [definitionId]); // eslint-disable-line react-hooks/exhaustive-deps const onConnect = useCallback( (connection: Connection) => { @@ -136,17 +163,18 @@ export default function ProcessDesigner({ onSave }: ProcessDesignerProps) { id: n.id, type: (n.data.nodeType as NodeDef['type']) || 'UserTask', name: n.data.name || String(n.data.label), + position: { x: Math.round(n.position.x), y: Math.round(n.position.y) }, })); const flowEdges: EdgeDef[] = edges.map((e) => ({ id: e.id, source: e.source, target: e.target, + label: e.label ? String(e.label) : undefined, })); - onSave({ - ...values, - nodes: flowNodes, - edges: flowEdges, - }); + onSave( + { ...values, nodes: flowNodes, edges: flowEdges }, + definitionId || undefined, + ); }).catch(() => { message.error('请填写必要字段'); }); @@ -159,6 +187,10 @@ export default function ProcessDesigner({ onSave }: ProcessDesignerProps) { [], ); + if (loading) { + return
; + } + return (
{/* 左侧工具面板 */} @@ -224,7 +256,7 @@ export default function ProcessDesigner({ onSave }: ProcessDesignerProps) { - + @@ -232,10 +264,7 @@ export default function ProcessDesigner({ onSave }: ProcessDesignerProps) { - - - - +
diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs index 400ffba..3ec66ad 100644 --- a/crates/erp-auth/src/service/user_service.rs +++ b/crates/erp-auth/src/service/user_service.rs @@ -136,13 +136,11 @@ impl UserService { .filter(user::Column::TenantId.eq(tenant_id)) .filter(user::Column::DeletedAt.is_null()); - if let Some(term) = search { - if !term.is_empty() { - use sea_orm::sea_query::Expr; - query = query.filter( - Expr::col(user::Column::Username).like(format!("%{}%", term)), - ); - } + if let Some(term) = search && !term.is_empty() { + use sea_orm::sea_query::Expr; + query = query.filter( + Expr::col(user::Column::Username).like(format!("%{}%", term)), + ); } let paginator = query.paginate(db, pagination.limit()); diff --git a/crates/erp-server/src/handlers/health.rs b/crates/erp-server/src/handlers/health.rs index e92e063..4af07ea 100644 --- a/crates/erp-server/src/handlers/health.rs +++ b/crates/erp-server/src/handlers/health.rs @@ -13,7 +13,7 @@ pub struct HealthResponse { pub modules: Vec, } -/// GET /api/v1/health +/// GET /health /// /// 服务健康检查,返回运行状态和已注册模块列表 pub async fn health_check(State(state): State) -> Json { @@ -32,5 +32,5 @@ pub async fn health_check(State(state): State) -> Json } pub fn health_check_router() -> Router { - Router::new().route("/api/v1/health", get(health_check)) + Router::new().route("/health", get(health_check)) } diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 99f8abb..f00a795 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -12,11 +12,11 @@ mod state; description = "ERP 平台底座 REST API 文档" ) )] +#[allow(dead_code)] struct ApiDoc; use axum::Router; use axum::middleware; -use axum::response::Json; use config::AppConfig; use erp_auth::middleware::jwt_auth_middleware_fn; use state::AppState; @@ -168,9 +168,6 @@ async fn main() -> anyhow::Result<()> { let public_routes = Router::new() .merge(handlers::health::health_check_router()) .merge(erp_auth::AuthModule::public_routes()) - .route("/api/docs/openapi.json", axum::routing::get(|| async { - Json(ApiDoc::openapi()) - })) .with_state(state.clone()); // Protected routes (JWT authentication required) @@ -185,10 +182,10 @@ async fn main() -> anyhow::Result<()> { .with_state(state.clone()); // Merge public + protected into the final application router + // All API routes are nested under /api/v1 let cors = build_cors_layer(&state.config.cors.allowed_origins); let app = Router::new() - .merge(public_routes) - .merge(protected_routes) + .nest("/api/v1", public_routes.merge(protected_routes)) .layer(cors); let addr = format!("{}:{}", host, port); diff --git a/plans/bubbly-squishing-lerdorf.md b/plans/bubbly-squishing-lerdorf.md new file mode 100644 index 0000000..e6bd117 --- /dev/null +++ b/plans/bubbly-squishing-lerdorf.md @@ -0,0 +1,210 @@ +# Phase 5-6 Implementation Plan: Message Center + Integration & Polish + +## Context + +Phase 1-4 已完成(core, auth, config, workflow)。现在需要实现 Phase 5(消息中心)和 Phase 6(整合与打磨)。`erp-message` crate 当前是空壳,需要完整实现。 + +--- + +## Phase 5: 消息中心 + +### Task 5.1: 数据库迁移 — 消息相关表 + +**新增 3 个迁移文件:** + +1. `m20260413_000023_create_message_templates.rs` — 消息模板表 + - id (UUID PK), tenant_id, name, code (唯一编码), channel (in_app/email/sms/wechat), + - title_template, body_template (支持 `{{variable}}` 插值), language (zh-CN/en-US), + - 标准审计字段 (created_at, updated_at, created_by, updated_by, deleted_at) + +2. `m20260413_000024_create_messages.rs` — 消息表 + - id (UUID PK), tenant_id, template_id (FK, nullable), + - sender_id (UUID, nullable=系统消息), sender_type (system/user), + - recipient_id (UUID, not null), recipient_type (user/role/department/all), + - title, body, priority (normal/important/urgent), + - business_type (workflow_task/system_notice/...), business_id (deep link ref), + - is_read (bool), read_at (nullable), + - is_archived (bool), archived_at (nullable), + - sent_at (nullable, for scheduled), status (pending/sent/recalled), + - 标准审计字段 + +3. `m20260413_000025_create_message_subscriptions.rs` — 消息订阅偏好 + - id (UUID PK), tenant_id, user_id, + - notification_types (JSON: 订阅的通知类型列表), + - channel_preferences (JSON: 各类型偏好的通道), + - dnd_enabled (bool), dnd_start (time), dnd_end (time), + - 标准审计字段 + +**修改文件:** +- `crates/erp-server/migration/src/lib.rs` — 注册 3 个新迁移 + +### Task 5.2: erp-message crate 基础结构 + +**修改/创建文件:** + +1. `crates/erp-message/Cargo.toml` — 补齐缺失依赖 (thiserror, utoipa, async-trait, validator, serde/uuid/chrono features, sea-orm features) +2. `crates/erp-message/src/lib.rs` — 声明子模块 + pub use +3. `crates/erp-message/src/message_state.rs` — MessageState { db, event_bus } +4. `crates/erp-message/src/error.rs` — MessageError 枚举 + From impls +5. `crates/erp-message/src/dto.rs` — 请求/响应 DTOs +6. `crates/erp-message/src/entity/mod.rs` — 实体子模块声明 +7. `crates/erp-message/src/entity/message_template.rs` +8. `crates/erp-message/src/entity/message.rs` +9. `crates/erp-message/src/entity/message_subscription.rs` +10. `crates/erp-message/src/module.rs` — MessageModule 实现 ErpModule + +### Task 5.3: 消息 CRUD 服务与处理器 + +**创建文件:** + +1. `crates/erp-message/src/service/mod.rs` +2. `crates/erp-message/src/service/message_service.rs` — 消息 CRUD + 发送 + 已读/未读 +3. `crates/erp-message/src/service/template_service.rs` — 模板 CRUD + 变量插值渲染 +4. `crates/erp-message/src/service/subscription_service.rs` — 订阅偏好 CRUD +5. `crates/erp-message/src/handler/mod.rs` +6. `crates/erp-message/src/handler/message_handler.rs` — 消息 API handlers +7. `crates/erp-message/src/handler/template_handler.rs` — 模板 API handlers +8. `crates/erp-message/src/handler/subscription_handler.rs` — 订阅 API handlers + +**路由设计:** +``` +GET /messages — 消息列表 (分页, 支持 status/priority/is_read 过滤) +GET /messages/unread-count — 未读消息数 +PUT /messages/{id}/read — 标记已读 +PUT /messages/read-all — 全部标记已读 +DELETE /messages/{id} — 删除消息 (软删除) +POST /messages/send — 发送消息 + +GET /message-templates — 模板列表 +POST /message-templates — 创建模板 + +PUT /message-subscriptions — 更新订阅偏好 +``` + +### Task 5.4: 服务器端集成 + +**修改文件:** + +1. `crates/erp-server/Cargo.toml` — 添加 erp-message 依赖 +2. `crates/erp-server/src/state.rs` — 添加 `FromRef for MessageState` +3. `crates/erp-server/src/main.rs` — 初始化并注册 MessageModule,合并路由 + +### Task 5.5: 前端 — 消息 API 与页面 + +**创建/修改文件:** + +1. `apps/web/src/api/messages.ts` — 消息 API 客户端 +2. `apps/web/src/api/messageTemplates.ts` — 模板 API 客户端 +3. `apps/web/src/pages/Messages.tsx` — 消息中心主页面 (Tabs: 通知列表/已归档) +4. `apps/web/src/pages/messages/NotificationList.tsx` — 通知列表子组件 +5. `apps/web/src/pages/messages/MessageTemplates.tsx` — 模板管理子组件 +6. `apps/web/src/pages/messages/NotificationPreferences.tsx` — 通知偏好设置 +7. `apps/web/src/App.tsx` — 添加 `/messages` 路由 +8. `apps/web/src/layouts/MainLayout.tsx` — 添加消息菜单项 + Bell 点击弹出通知面板 + +### Task 5.6: 通知面板与未读计数 + +**修改文件:** + +1. `apps/web/src/layouts/MainLayout.tsx` — Bell 图标添加 Badge (未读数) + Popover 通知面板 +2. `apps/web/src/stores/message.ts` — Zustand store: unreadCount, fetchUnread, recentMessages +3. `apps/web/src/components/NotificationPanel.tsx` — 通知弹出面板组件 + +--- + +## Phase 6: 整合与打磨 + +### Task 6.1: 跨模块事件集成 — 工作流 → 消息 + +**修改文件:** + +1. `crates/erp-message/src/module.rs` — `register_event_handlers()` 订阅工作流事件 +2. `crates/erp-message/src/service/message_service.rs` — 添加事件处理方法 +3. 订阅的事件: + - `workflow.instance.started` → 通知发起人 + - `workflow.task.created` → 通知待办人 + - `workflow.task.completed` → 通知发起人 + - `workflow.instance.completed` → 通知发起人 + - `workflow.instance.terminated` → 通知相关人 + +### Task 6.2: 审计日志 + +**创建/修改文件:** + +1. 迁移 `m20260413_000026_create_audit_logs.rs` — 审计日志表 + - id, tenant_id, user_id, action, resource_type, resource_id, + - old_value (JSON), new_value (JSON), ip_address, user_agent, + - 标准审计字段 +2. `crates/erp-core/src/audit.rs` — 审计中间件/工具函数 +3. `crates/erp-server/src/main.rs` — 应用审计中间件到 protected routes + +### Task 6.3: API 文档完善 + +**修改文件:** + +1. `crates/erp-server/src/main.rs` — 添加 utoipa Swagger UI 路由 +2. 各模块已有 utoipa 注解,确保正确注册到 OpenApi + +### Task 6.4: 安全审查 + +检查项: +- JWT 中间件正确性 +- 多租户隔离 (所有查询带 tenant_id) +- SQL 注入防护 (SeaORM 参数化) +- CORS 配置 +- 密码安全 (Argon2) +- 输入验证 (所有 API 端点) +- 错误信息不泄露敏感数据 + +### Task 6.5: 前端整合完善 + +**修改文件:** + +1. `apps/web/src/layouts/MainLayout.tsx` — 完善通知面板交互 +2. 工作流页面集成消息通知反馈 +3. 整体 UI 打磨和一致性检查 + +--- + +## 实施顺序 + +``` +Task 5.1 (迁移) + → Task 5.2 (crate 基础) + → Task 5.3 (服务+处理器) + → Task 5.4 (服务器集成) + → Task 5.5 (前端页面) + → Task 5.6 (通知面板) + → Task 6.1 (事件集成) + → Task 6.2 (审计日志) + → Task 6.3 (API 文档) + → Task 6.4 (安全审查) + → Task 6.5 (前端整合) +``` + +每个 Task 完成后立即提交。每个 Task 预计产生 1 个 commit。 + +## 验证方式 + +1. `cargo check` — 全 workspace 编译通过 +2. `cargo test --workspace` — 所有测试通过 +3. `cargo run -p erp-server` — 服务启动正常 +4. 浏览器验证消息 CRUD 流程 +5. 验证通知面板未读计数 +6. 验证工作流事件触发消息通知 +7. Swagger UI 验证 API 文档 + +## 关键文件索引 + +| 用途 | 文件路径 | +|------|---------| +| 迁移注册 | `crates/erp-server/migration/src/lib.rs` | +| 服务器入口 | `crates/erp-server/src/main.rs` | +| 状态桥接 | `crates/erp-server/src/state.rs` | +| 模块 trait | `crates/erp-core/src/module.rs` | +| 事件总线 | `crates/erp-core/src/events.rs` | +| 错误类型 | `crates/erp-core/src/error.rs` | +| 前端路由 | `apps/web/src/App.tsx` | +| 布局 | `apps/web/src/layouts/MainLayout.tsx` | +| API 客户端 | `apps/web/src/api/client.ts` | +| 参考模块 | `crates/erp-workflow/` (完整模式参考) | diff --git a/plans/lively-tickling-engelbart.md b/plans/lively-tickling-engelbart.md new file mode 100644 index 0000000..01edb87 --- /dev/null +++ b/plans/lively-tickling-engelbart.md @@ -0,0 +1,269 @@ +# Phase 2: 身份与权限模块实施计划 + +## Context + +Phase 1 基础设施已完成(workspace、core types、EventBus、ErpModule trait、AppState、health check、graceful shutdown)。现在需要构建 Phase 2 身份与权限模块,这是所有后续模块的基础——工作流、消息、配置都依赖认证和权限中间件。 + +**目标**:实现完整的用户认证(JWT)、RBAC 权限模型、多租户中间件、用户/角色/组织管理 CRUD,以及对应的前端页面。 + +**范围界定**:Phase 2 仅实现用户名/密码认证 + RBAC。OAuth/SSO/TOTP/ABAC 延后到后续 Phase。 + +--- + +## 关键决策 + +1. **ErpModule trait 路由问题**:当前 trait `register_routes` 使用 `Router`(无状态泛型),但实际路由需要 `Router`。Phase 2 采用**务实方案**:模块暴露独立 `routes() -> Router` 函数,server 手动 merge。不改动 trait,避免核心层依赖 server 类型。 +2. **Token 存储**:JWT 的 SHA-256 哈希存入 `user_tokens` 表,支持吊销。前端用 localStorage 存储 access/refresh token(httpOnly cookie 为 Phase 6 优化项)。 +3. **中间件方案**:使用 `axum::middleware::from_fn_with_state` 实现 JWT 认证中间件,将 `TenantContext` 注入 `req.extensions()`。 + +--- + +## Task 1: 数据库迁移(10 张表) + +**目标**:创建所有 Auth 相关的数据库表。 + +**创建文件**(`crates/erp-server/migration/src/`): +- `m20260411_000002_create_users.rs` +- `m20260411_000003_create_user_credentials.rs` +- `m20260411_000004_create_user_tokens.rs` +- `m20260411_000005_create_roles.rs` +- `m20260411_000006_create_permissions.rs` +- `m20260411_000007_create_role_permissions.rs` +- `m20260411_000008_create_user_roles.rs` +- `m20260411_000009_create_organizations.rs` +- `m20260411_000010_create_departments.rs` +- `m20260411_000011_create_positions.rs` + +**修改文件**:`migration/src/lib.rs` 注册所有新迁移 + +**表结构要点**: +- 所有表含标准字段:id(UUID PK), tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version +- **users**: username(VARCHAR, 复合唯一 tenant_id+username), email, phone, display_name, avatar_url, status(CHECK active/disabled/locked), last_login_at +- **user_credentials**: user_id FK, credential_type(CHECK password/oauth/sso), credential_data(JSONB), verified(bool) +- **user_tokens**: user_id FK, token_hash(VARCHAR UNIQUE), token_type(CHECK access/refresh), expires_at, revoked_at, device_info +- **roles**: name, code(复合唯一 tenant_id+code), description, is_system(bool) +- **permissions**: code(复合唯一 tenant_id+code), name, resource, action, description +- **role_permissions**: role_id + permission_id 复合 PK +- **user_roles**: user_id + role_id 复合 PK +- **organizations**: name, code, parent_id(自引用 FK), path(TEXT), level, sort_order +- **departments**: org_id FK, name, code, parent_id(自引用), manager_id FK users, path, sort_order +- **positions**: dept_id FK, name, code, level, sort_order + +**验证**:`cargo run -p erp-server` → `\dt` 显示全部 11 张表 + +--- + +## Task 2: SeaORM Entity 定义 + +**目标**:为所有 Auth 表创建类型安全的 Entity/Model/Relation。 + +**创建文件**(`crates/erp-auth/src/entity/`): +- `mod.rs`, `user.rs`, `user_credential.rs`, `user_token.rs`, `role.rs`, `permission.rs`, `role_permission.rs`, `user_role.rs`, `organization.rs`, `department.rs`, `position.rs` + +**修改文件**: +- `crates/erp-auth/Cargo.toml` — 添加 workspace 依赖:jsonwebtoken, argon2, validator, thiserror, utoipa, erp-common + +**同时创建 DTO 文件**:`crates/erp-auth/src/dto.rs` +- `LoginReq` { username, password } — #[derive(Validate)] +- `LoginResp` { access_token, refresh_token, expires_in, user } +- `RefreshReq` { refresh_token } +- `CreateUserReq` { username, password, email?, phone?, display_name? } +- `UpdateUserReq` { email?, phone?, display_name?, status? } +- `UserResp`, `RoleResp`, `PermissionResp` 等 +- `CreateRoleReq`, `UpdateRoleReq`, `AssignPermissionsReq`, `AssignRolesReq` +- 所有 DTO 添加 `#[derive(utoipa::ToSchema)]` + +**验证**:`cargo check -p erp-auth` 通过 + +--- + +## Task 3: 核心服务层(密码 + JWT + 认证) + +**目标**:实现密码哈希、JWT 签发/验证、登录/刷新/登出逻辑。 + +**创建文件**(`crates/erp-auth/src/`): +- `error.rs` — AuthError 枚举 + From for AppError +- `service/mod.rs` +- `service/password.rs` — Argon2 hash/verify +- `service/token_service.rs` — JWT sign/validate, token DB CRUD +- `service/auth_service.rs` — login/refresh/logout +- `service/user_service.rs` — user CRUD + +**修改文件**:`crates/erp-auth/src/lib.rs` — 声明模块 + +**关键实现**: +- `password.rs`: `hash_password(plain) -> Result`, `verify_password(plain, hash) -> Result` +- `token_service.rs`: + - JWT Claims: { sub: Uuid, tid: Uuid, roles: Vec, permissions: Vec, exp, iat, token_type: String } + - `sign_access_token(user_id, tenant_id, roles, permissions, jwt_config) -> Result` + - `sign_refresh_token(user_id, tenant_id, db, jwt_config) -> Result<(String, Uuid)>` — 存 SHA-256 哈希到 DB + - `validate_refresh_token(token, db, jwt_config) -> Result<(Uuid, Claims)>` — 检查吊销状态 + - `revoke_all_user_tokens(user_id, tenant_id, db)` +- `auth_service.rs`: + - `login(tenant_id, username, password, device_info, db, token_svc, event_bus)` → 查用户 → 验证密码 → 签发双 token → 更新 last_login_at → 发 user.login 事件 + - `refresh(refresh_token, db, token_svc)` → 验证 → 吊销旧 refresh → 签发新双 token + - `logout(user_id, tenant_id, db, token_svc)` → 吊销全部 token +- `user_service.rs`: CRUD,查询始终带 tenant_id 过滤 + deleted_at IS NULL + +**验证**:`cargo check -p erp-auth` 通过,password hash/verify 单元测试通过 + +--- + +## Task 4: Handler 路由 + AuthModule 注册 + +**目标**:创建 Axum handler,注册到 server。 + +**创建文件**(`crates/erp-auth/src/`): +- `handler/mod.rs` +- `handler/auth_handler.rs` — login/refresh/logout +- `handler/user_handler.rs` — user CRUD + 角色分配 +- `module.rs` — AuthModule struct + ErpModule impl + +**修改文件**: +- `crates/erp-server/Cargo.toml` — 添加 `erp-auth.workspace = true` +- `crates/erp-server/src/main.rs` — 注册 auth 模块路由 +- `crates/erp-server/src/config.rs` — 添加 `AuthConfig { super_admin_password }` +- `crates/erp-server/config/default.toml` — 添加 `[auth]` 段 + +**路由设计**: +- 公开(无需 JWT):`POST /api/v1/auth/login`, `POST /api/v1/auth/refresh` +- 受保护(需 JWT):`POST /api/v1/auth/logout`, 全部 users/roles/permissions/orgs 路由 + +**main.rs 路由结构**: +``` +Router::new() + .merge(public_routes) // health + login + refresh + .merge(protected_routes) // logout + user/role CRUD, 后续加 JWT 中间件 + .with_state(state) +``` + +**验证**:server 启动,`POST /api/v1/auth/login` 返回 400(无 body)而非 404 + +--- + +## Task 5: JWT 认证中间件 + RBAC 权限检查 + +**目标**:JWT 验证中间件注入 TenantContext,RBAC 权限检查辅助函数。 + +**创建文件**(`crates/erp-auth/src/`): +- `middleware/mod.rs` +- `middleware/jwt_auth.rs` — `jwt_auth_middleware(State, Request, Next)` → 从 Bearer token 解码 Claims → 注入 TenantContext 到 extensions +- `middleware/rbac.rs` — `require_permission(perm: &str, ctx: &TenantContext) -> AppResult<()>` + +**修改文件**: +- `crates/erp-server/src/main.rs` — public/protected 路由分离,protected 路由层加 JWT 中间件 + +**关键实现**: +- 中间件用 `axum::middleware::from_fn_with_state(state, jwt_auth_middleware)` +- 从 `Authorization: Bearer xxx` 提取 token → decode → 检查过期 → 注入 `TenantContext { tenant_id, user_id, roles, permissions }` +- RBAC: 简单检查 `ctx.permissions.contains(&required_permission)` +- login/refresh/health 路由跳过 JWT 中间件 + +**验证**:无 token 访问 `/api/v1/users` 返回 401;login/refresh 不受影响 + +--- + +## Task 6: 租户初始化钩子 + 种子数据 + +**目标**:实现 `on_tenant_created` 创建默认角色/权限/管理员。 + +**创建文件**:`crates/erp-auth/src/service/seed.rs` + +**修改文件**: +- `crates/erp-auth/src/module.rs` — 实现 `on_tenant_created` +- `crates/erp-server/src/main.rs` — server 启动时自动创建默认租户(开发用) + +**种子数据**: +- 20 个默认权限(user:crud, role:crud, permission:read, organization:crud, department:crud, position:crud) +- "admin" 角色(is_system=true)绑定所有权限 +- "viewer" 角色(is_system=true)绑定只读权限 +- super admin 用户(username="admin",密码从配置读取) + +**验证**:启动后查询 DB 有 admin 用户、admin/viewer 角色、20 个权限 + +--- + +## Task 7: 前端登录页 + Auth Store + API 层 + +**目标**:登录 UI、token 管理、路由守卫。 + +**创建文件**(`apps/web/src/`): +- `api/client.ts` — axios 实例 + 请求/响应拦截器(附加 token、401 自动 refresh) +- `api/auth.ts` — login/refresh/logout API 调用 +- `api/users.ts` — 用户 CRUD API +- `stores/auth.ts` — auth Zustand store(user, permissions, tokens, login/logout) +- `pages/Login.tsx` — 登录表单页 +- `pages/Home.tsx` — 首页(从 App.tsx 抽取) + +**修改文件**: +- `apps/web/src/App.tsx` — 添加 PrivateRoute 守卫、/login 路由 +- `apps/web/src/stores/app.ts` — 移除 isLoggedIn stub(由 auth store 接管) + +**验证**:打开 `/#/` 自动跳转 `/login`,输入 admin/Admin@2026 登录后跳转到首页,刷新保持登录状态 + +--- + +## Task 8: 角色/权限管理 + +**目标**:角色 CRUD + 权限分配后端 + 前端页面。 + +**创建文件**: +- Backend: `crates/erp-auth/src/service/role_service.rs`, `service/permission_service.rs`, `handler/role_handler.rs`, `handler/permission_handler.rs` +- Frontend: `apps/web/src/pages/Roles.tsx`, `apps/web/src/api/roles.ts` + +**修改文件**:`module.rs` 注册新路由, `App.tsx` 添加路由 + +**验证**:admin 登录 → 角色管理 → 创建角色 → 分配权限 → DB 验证 + +--- + +## Task 9: 组织/部门/岗位管理 + +**目标**:树形组织架构管理。 + +**创建文件**: +- Backend: `service/org_service.rs`, `service/dept_service.rs`, `service/position_service.rs`, `handler/org_handler.rs`, `handler/dept_handler.rs`, `handler/position_handler.rs` +- Frontend: `apps/web/src/pages/Organizations.tsx`, `apps/web/src/api/orgs.ts` + +**关键**:树形结构用 parent_id + path 列实现祖先查询 + +**验证**:创建根组织 → 子组织 → 部门 → 岗位,验证 path 正确 + +--- + +## Task 10: 用户管理页面 + 整合 + +**目标**:完整的用户 CRUD 界面 + 角色分配 + 主布局用户信息。 + +**创建文件**: +- `apps/web/src/pages/Users.tsx` — Ant Design Table + 创建/编辑/角色分配 Modal +- `apps/web/src/layouts/MainLayout.tsx` — 更新:显示当前用户名、登出菜单 + +**验证**:admin 登录 → 用户管理 → 创建用户 → 分配角色 → 禁用/启用 → 所有操作即时反映 + +--- + +## 依赖关系 + +``` +Task 1 (迁移) → Task 2 (Entity) → Task 3 (服务层) → Task 4 (Handler+注册) + ↓ + Task 5 (JWT 中间件) + Task 6 (种子数据) + ↓ + Task 7 (前端登录) + ↓ + Task 8 (角色权限) Task 9 (组织部门) + ↓ + Task 10 (用户管理 UI) +``` + +## 验证标准 + +- [ ] `cargo check --workspace` 零错误零警告 +- [ ] `cargo test --workspace` 全部通过 +- [ ] server 启动后可 login → 获得 JWT → 带 JWT 访问 /api/v1/users +- [ ] 无 JWT 访问受保护端点返回 401 +- [ ] 前端登录 → 跳转首页 → 刷新保持 → 登出清除 +- [ ] 用户/角色/组织 CRUD 页面功能完整 +- [ ] 多租户隔离:每个查询自动带 tenant_id 过滤 diff --git a/plans/stateless-swimming-perlis.md b/plans/stateless-swimming-perlis.md index 96c4330..5fec116 100644 --- a/plans/stateless-swimming-perlis.md +++ b/plans/stateless-swimming-perlis.md @@ -1,135 +1,137 @@ -# Phase 3: 系统配置模块 — 实施计划 +# Phase 4: 工作流引擎模块 — 实施计划 ## Context -Phase 1(基础设施)和 Phase 2(身份与权限)已完成。Phase 3 需要实现系统配置模块(erp-config),提供数据字典、动态菜单、系统参数、编号规则、i18n 框架和主题自定义能力。当前 `erp-config` 仅为 placeholder。 +Phase 1(基础设施)、Phase 2(身份与权限)和 Phase 3(系统配置)已完成。Phase 4 需要实现工作流引擎模块(erp-workflow),提供流程定义、流程实例管理、任务审批、Token 驱动的执行引擎和可视化流程设计器。当前 `erp-workflow` 仅为 placeholder。 -## 前置重构:将 RBAC 移至 erp-core - -**问题:** `require_permission` 在 `erp-auth/src/middleware/rbac.rs` 中,但只依赖 `erp-core` 的 `TenantContext` 和 `AppError`。erp-config 不能依赖 erp-auth(架构铁律:业务 crate 禁止直接依赖)。 - -**方案:** -1. 将 `erp-auth/src/middleware/rbac.rs` → `erp-core/src/rbac.rs` -2. `erp-core/src/lib.rs` 添加 `pub mod rbac;` -3. 更新 `erp-auth` 所有 handler 的 import:`crate::middleware::rbac` → `erp_core::rbac` -4. `erp-auth/src/middleware/mod.rs` 移除 `pub mod rbac;` +用户选择"完整实施"方案,包括 BPMN 子集解析器、Token 驱动执行引擎、完整 CRUD 端点和 React Flow 可视化设计器。 --- -## Task 1: erp-config 骨架 + ConfigState +## Task 1: erp-workflow 骨架 + WorkflowState + Error **目标:** 创建可编译的最小 crate,注册到 erp-server。 **创建/修改文件:** -- 修改: `crates/erp-config/Cargo.toml` — 添加完整依赖 -- 修改: `crates/erp-config/src/lib.rs` — 模块声明 + re-export -- 创建: `crates/erp-config/src/config_state.rs` — ConfigState { db, event_bus } -- 创建: `crates/erp-config/src/error.rs` — ConfigError 枚举 -- 创建: `crates/erp-config/src/module.rs` — ConfigModule + 空路由 -- 创建: `crates/erp-config/src/dto.rs` — 占位 -- 创建: `crates/erp-config/src/entity/mod.rs` — 占位 -- 创建: `crates/erp-config/src/service/mod.rs` — 占位 -- 创建: `crates/erp-config/src/handler/mod.rs` — 占位 -- 修改: `crates/erp-server/Cargo.toml` — 添加 erp-config 依赖 -- 修改: `crates/erp-server/src/state.rs` — 添加 FromRef for ConfigState -- 修改: `crates/erp-server/src/main.rs` — 注册 ConfigModule +- 修改: `crates/erp-workflow/Cargo.toml` — 添加完整依赖 +- 修改: `crates/erp-workflow/src/lib.rs` — 模块声明 + re-export +- 创建: `crates/erp-workflow/src/workflow_state.rs` — `WorkflowState { db, event_bus }` +- 创建: `crates/erp-workflow/src/error.rs` — WorkflowError 枚举 +- 创建: `crates/erp-workflow/src/module.rs` — WorkflowModule + ErpModule trait +- 创建: `crates/erp-workflow/src/dto.rs` — 占位 +- 创建: `crates/erp-workflow/src/entity/mod.rs` — 占位 +- 创建: `crates/erp-workflow/src/service/mod.rs` — 占位 +- 创建: `crates/erp-workflow/src/handler/mod.rs` — 占位 +- 创建: `crates/erp-workflow/src/engine/mod.rs` — 占位 +- 修改: `crates/erp-server/Cargo.toml` — 确认 erp-workflow 依赖 +- 修改: `crates/erp-server/src/state.rs` — 添加 `FromRef` for WorkflowState +- 修改: `crates/erp-server/src/main.rs` — 注册 WorkflowModule + +**依赖:** +```toml +erp-core.workspace = true +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +uuid = { workspace = true, features = ["v7", "serde"] } +chrono = { workspace = true, features = ["serde"] } +axum = { workspace = true } +sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls", "with-uuid", "with-chrono", "with-json"] } +tracing = { workspace = true } +thiserror = { workspace = true } +utoipa = { workspace = true, features = ["uuid", "chrono"] } +async-trait = { workspace = true } +``` **验证:** `cargo check` 通过 --- -## Task 2: 数据库迁移(6 张表) - -**目标:** 创建所有配置模块表。 +## Task 2: 数据库迁移(5 张表) **创建文件(`crates/erp-server/migration/src/`):** -- `m20260412_000012_create_dictionaries.rs` — 字典分类表 -- `m20260412_000013_create_dictionary_items.rs` — 字典项表 -- `m20260412_000014_create_menus.rs` — 菜单树形表 -- `m20260412_000015_create_menu_roles.rs` — 菜单-角色关联表 -- `m20260412_000016_create_settings.rs` — 分层键值配置表 -- `m20260412_000017_create_numbering_rules.rs` — 编号规则表 +- `m20260412_000018_create_process_definitions.rs` +- `m20260412_000019_create_process_instances.rs` +- `m20260412_000020_create_tokens.rs` +- `m20260412_000021_create_tasks.rs` +- `m20260412_000022_create_process_variables.rs` - 修改: `lib.rs` — 注册新迁移 -**表结构:** - -### dictionaries +### process_definitions | 列 | 类型 | 说明 | |---|---|---| | id | uuid PK | UUIDv7 | | tenant_id | uuid NOT NULL | 租户 ID | -| name | string NOT NULL | 显示名称 | -| code | string NOT NULL | 字典键(如 gender) | -| description | text NULL | 说明 | +| name | string NOT NULL | 流程名称 | +| key | string NOT NULL | 流程唯一编码 | +| version | int NOT NULL DEFAULT 1 | 版本号 | +| category | string NULL | 分类(如 leave, expense) | +| description | text NULL | 描述 | +| nodes | jsonb NOT NULL DEFAULT '[]' | 节点定义(BPMN 子集) | +| edges | jsonb NOT NULL DEFAULT '[]' | 连线定义 | +| status | string NOT NULL DEFAULT 'draft' | draft/published/deprecated | | + 标准审计字段 | | | -| 唯一索引: | `(tenant_id, code) WHERE deleted_at IS NULL` | | +| 唯一索引: | `(tenant_id, key, version) WHERE deleted_at IS NULL` | | -### dictionary_items +### process_instances | 列 | 类型 | 说明 | |---|---|---| | id | uuid PK | UUIDv7 | | tenant_id | uuid NOT NULL | | -| dictionary_id | uuid NOT NULL | FK → dictionaries | -| label | string NOT NULL | 显示标签 | -| value | string NOT NULL | 存储值 | -| sort_order | int DEFAULT 0 | 排序 | -| color | string NULL | 颜色标签 | +| definition_id | uuid NOT NULL FK → process_definitions | | +| business_key | string NULL | 业务关联键(如请假单 ID) | +| status | string NOT NULL DEFAULT 'running' | running/suspended/completed/terminated | +| started_by | uuid NOT NULL | 发起人 user_id | +| started_at | timestamptz NOT NULL DEFAULT NOW() | | +| completed_at | timestamptz NULL | | | + 标准审计字段 | | | -| 唯一索引: | `(dictionary_id, value) WHERE deleted_at IS NULL` | | +| 索引: | `idx_instances_status (tenant_id, status)` | | -### menus(树形自引用) +### tokens | 列 | 类型 | 说明 | |---|---|---| | id | uuid PK | UUIDv7 | | tenant_id | uuid NOT NULL | | -| parent_id | uuid NULL | 自引用 | -| title | string NOT NULL | 菜单标题 | -| path | string NULL | 前端路由 | -| icon | string NULL | 图标名 | -| sort_order | int DEFAULT 0 | | -| visible | bool DEFAULT true | | -| menu_type | string DEFAULT 'page' | page/link/button | -| permission | string NULL | 所需权限码 | -| + 标准审计字段 | | | +| instance_id | uuid NOT NULL FK → process_instances | | +| node_id | string NOT NULL | 当前所在节点 ID | +| status | string NOT NULL DEFAULT 'active' | active/consumed/terminated | +| created_at | timestamptz NOT NULL DEFAULT NOW() | | +| consumed_at | timestamptz NULL | | +| 索引: | `idx_tokens_instance (instance_id)` | | -### menu_roles(复合主键) -| 列 | 类型 | 说明 | -|---|---|---| -| menu_id | uuid NOT NULL | PK 组成 | -| role_id | uuid NOT NULL | PK 组成 | -| tenant_id | uuid NOT NULL | | -| + 标准审计字段 | | | -| 唯一索引: | `(menu_id, role_id) WHERE deleted_at IS NULL` | | - -### settings(分层配置) +### tasks | 列 | 类型 | 说明 | |---|---|---| | id | uuid PK | UUIDv7 | | tenant_id | uuid NOT NULL | | -| scope | string NOT NULL | platform/tenant/org/user | -| scope_id | uuid NULL | 平台=NULL, 租户=tenant_id 等 | -| setting_key | string NOT NULL | | -| setting_value | jsonb DEFAULT '{}' | | +| instance_id | uuid NOT NULL FK → process_instances | | +| token_id | uuid NOT NULL FK → tokens | | +| node_id | string NOT NULL | 对应的流程节点 | +| node_name | string NULL | 节点名称(冗余,便于查询) | +| assignee_id | uuid NULL | 指定处理人 | +| candidate_groups | jsonb NULL | 候选角色组 | +| status | string NOT NULL DEFAULT 'pending' | pending/approved/rejected/delegated | +| outcome | string NULL | 审批结果 | +| form_data | jsonb NULL | 表单数据 | +| due_date | timestamptz NULL | 到期时间 | +| completed_at | timestamptz NULL | | | + 标准审计字段 | | | -| 唯一索引: | `(scope, scope_id, setting_key) WHERE deleted_at IS NULL` | | +| 索引: | `idx_tasks_assignee (tenant_id, assignee_id, status)` | | +| 索引: | `idx_tasks_instance (instance_id)` | | -### numbering_rules +### process_variables | 列 | 类型 | 说明 | |---|---|---| | id | uuid PK | UUIDv7 | | tenant_id | uuid NOT NULL | | -| name | string NOT NULL | 规则名称 | -| code | string NOT NULL | 唯一编码(如 INV) | -| prefix | string DEFAULT '' | 前缀 | -| date_format | string NULL | 如 %Y%m%d | -| seq_length | int DEFAULT 4 | 序列位数 | -| seq_start | int DEFAULT 1 | 起始值 | -| seq_current | bigint DEFAULT 0 | 当前序列 | -| separator | string DEFAULT '-' | 分隔符 | -| reset_cycle | string DEFAULT 'never' | never/daily/monthly/yearly | -| last_reset_date | date NULL | | -| + 标准审计字段 | | | -| 唯一索引: | `(tenant_id, code) WHERE deleted_at IS NULL` | | +| instance_id | uuid NOT NULL FK → process_instances | | +| name | string NOT NULL | 变量名 | +| var_type | string NOT NULL DEFAULT 'string' | string/number/boolean/date/json | +| value_string | text NULL | | +| value_number | double precision NULL | | +| value_boolean | boolean NULL | | +| value_date | timestamptz NULL | | +| 唯一索引: | `(instance_id, name)` | | **验证:** `cargo run -p erp-server` 启动后 `\dt` 可见新表 @@ -137,18 +139,15 @@ Phase 1(基础设施)和 Phase 2(身份与权限)已完成。Phase 3 需 ## Task 3: SeaORM Entity -**目标:** 为 6 张表创建 Entity 定义。 - -**创建文件(`crates/erp-config/src/entity/`):** +**创建文件(`crates/erp-workflow/src/entity/`):** - `mod.rs` — 导出所有实体 -- `dictionary.rs` — Dictionary Entity -- `dictionary_item.rs` — DictionaryItem Entity -- `menu.rs` — Menu Entity -- `menu_role.rs` — MenuRole Entity(复合主键模式,参考 role_permission) -- `setting.rs` — Setting Entity -- `numbering_rule.rs` — NumberingRule Entity +- `process_definition.rs` +- `process_instance.rs` +- `token.rs` +- `task.rs` +- `process_variable.rs` -**模式:** 参考 `erp-auth/src/entity/role.rs`,包含 Relation 和 Related 实现。 +**模式:** 参考 `erp-config/src/entity/numbering_rule.rs`,包含 Relation 和 Related。 **验证:** `cargo check` 通过 @@ -156,174 +155,215 @@ Phase 1(基础设施)和 Phase 2(身份与权限)已完成。Phase 3 需 ## Task 4: DTO 定义 -**目标:** 定义所有配置端点的请求/响应类型。 - -**修改文件:** `crates/erp-config/src/dto.rs` +**修改文件:** `crates/erp-workflow/src/dto.rs` **包含:** -- 字典 DTO:DictionaryResp, DictionaryItemResp, CreateDictionaryReq, UpdateDictionaryReq, CreateDictionaryItemReq, UpdateDictionaryItemReq -- 菜单 DTO:MenuResp(含 children 递归), CreateMenuReq, UpdateMenuReq, BatchSaveMenuReq -- 设置 DTO:SettingResp, UpdateSettingReq -- 编号规则 DTO:NumberingRuleResp, CreateNumberingRuleReq, UpdateNumberingRuleReq, GenerateNumberResp -- 主题 DTO:ThemeResp, UpdateThemeReq(委托 settings 存储) -- 语言 DTO:LanguageResp, UpdateLanguageReq(委托 settings 存储) +- 流程定义:`ProcessDefinitionResp`, `CreateProcessDefinitionReq`, `UpdateProcessDefinitionReq`, `PublishDefinitionReq` +- 流程实例:`ProcessInstanceResp`, `StartInstanceReq` +- 任务:`TaskResp`, `CompleteTaskReq`(含 outcome + form_data) +- 流程变量:`ProcessVariableResp`, `SetVariableReq` +- 流程图:`NodeDef`(BPMN 节点), `EdgeDef`(连线), `FlowDiagram`(完整图) + +**节点类型:** StartEvent, EndEvent, UserTask, ServiceTask, ExclusiveGateway, ParallelGateway +**连线条件:** `condition` 字段为可选表达式字符串(如 `amount > 1000`) **验证:** `cargo check` 通过 --- -## Task 5: Service — 字典 + 系统参数 +## Task 5: BPMN 解析器 + 表达式引擎 -**创建文件(`crates/erp-config/src/service/`):** -- `dictionary_service.rs` — CRUD + 字典项管理 + 按 code 查询 -- `setting_service.rs` — 分层读取(User>Org>Tenant>Platform)+ 写入 +**创建文件(`crates/erp-workflow/src/engine/`):** +- `model.rs` — 流程图内存模型(FlowDiagram, FlowNode, FlowEdge, NodeType 枚举) +- `parser.rs` — 解析 JSON nodes/edges 为内存模型,验证流程图合法性 +- `expression.rs` — 简单表达式求值器(支持比较运算和流程变量引用) **关键逻辑:** -- SettingService::get 实现分层覆盖查找 -- SettingService::set 使用 upsert 语义(INSERT ON CONFLICT UPDATE) -- DictionaryService 遵循 RoleService 的无状态模式 +- `FlowDiagram::validate()` — 检查:恰好 1 个 StartEvent,至少 1 个 EndEvent,无悬空连线,网关分支/汇合配对 +- `ExpressionEvaluator::eval(expr, variables) -> bool` — 支持 `var > 1000`, `status == "approved"`, `amount <= budget` 格式 +- 解析器将 `nodes` 和 `edges` jsonb 反序列化为 `Vec` 和 `Vec` + +**验证:** 单元测试覆盖:合法流程验证、缺少 StartEvent 报错、表达式求值 --- -## Task 6: Service — 菜单 + 编号规则 +## Task 6: Token 驱动执行引擎 -**创建文件(`crates/erp-config/src/service/`):** -- `menu_service.rs` — 菜单树构建 + 角色过滤 + 批量保存 -- `numbering_service.rs` — 编号规则 CRUD + generate_number +**创建文件:** `crates/erp-workflow/src/engine/executor.rs` -**关键逻辑:** -- MenuService::get_menu_tree — 按 role_ids 过滤 menu_role,HashMap 分组构建树(参考 OrgService 的 build_org_tree) -- NumberingService::generate_number — PostgreSQL advisory_lock + 事务内序列递增: - ```sql - SELECT pg_advisory_xact_lock(hashtext($1), $2::int) - -- $1 = rule_code, $2 = hash(tenant_id) - ``` - 生成格式:`{prefix}{separator}{date}{separator}{seq_padded}` +**核心逻辑:** +``` +start(instance_id, definition) → 在 StartEvent 创建 token +advance(token_id, instance_id, definition, variables) → 消费当前 token,在下一节点创建新 token + - 到达 EndEvent → 消费 token,检查实例是否所有 token 都完成 → 完成实例 + - 到达 UserTask → 创建 token + 创建 task 记录 + - 到达 ServiceTask → 创建 token + 执行动作(占位,发布事件) + - 到达 ExclusiveGateway → 求值条件,选择一条分支 + - 到达 ParallelGateway(分支)→ 为每条出边创建 token + - 到达 ParallelGateway(汇合)→ 消费当前 token,等待所有入边 token 到达后创建新 token +``` + +**并发安全:** 使用 `pg_advisory_xact_lock` 保护 token 操作(参考 NumberingService 模式) + +**验证:** 单元测试覆盖:直线流程、排他网关分支、并行网关分支与汇合 --- -## Task 7: Handler 层 +## Task 7: Service 层 -**创建文件(`crates/erp-config/src/handler/`):** -- `dictionary_handler.rs` — 5 个端点 -- `menu_handler.rs` — 2 个端点 -- `setting_handler.rs` — 2 个端点 -- `numbering_handler.rs` — 4 个端点(含 generate) -- `theme_handler.rs` — 2 个端点(委托 SettingService) -- `language_handler.rs` — 2 个端点(委托 SettingService) +**创建文件(`crates/erp-workflow/src/service/`):** +- `definition_service.rs` — 流程定义 CRUD + 发布 + 版本管理 +- `instance_service.rs` — 启动实例 + 查询 + 挂起/恢复/终止 +- `task_service.rs` — 查询待办 + 完成任务 + 委派 + 查询已办 + +**关键逻辑:** +- `DefinitionService::publish` — draft → published,验证流程图合法性 +- `InstanceService::start` — 创建实例 + 初始化变量 + 调用 executor.start +- `TaskService::complete` — 更新 task 状态 + 调用 executor.advance + 处理下一节点 + +--- + +## Task 8: Handler 层 + +**创建文件(`crates/erp-workflow/src/handler/`):** +- `definition_handler.rs` — 5 个端点 +- `instance_handler.rs` — 4 个端点 +- `task_handler.rs` — 4 个端点 **端点映射:** ``` -GET/POST /config/dictionaries -PUT/DELETE /config/dictionaries/{id} -GET/PUT /config/menus -GET/PUT /config/settings/{key} -GET/POST /config/numbering-rules -PUT /config/numbering-rules/{id} -POST /config/numbering-rules/{id}/generate -GET/PUT /config/themes -GET /config/languages -PUT /config/languages/{code} +GET/POST /workflow/definitions +GET /workflow/definitions/{id} +PUT /workflow/definitions/{id} +POST /workflow/definitions/{id}/publish +POST /workflow/instances +GET /workflow/instances +GET /workflow/instances/{id} +POST /workflow/instances/{id}/suspend +POST /workflow/instances/{id}/terminate +GET /workflow/tasks/pending — 我的待办 +GET /workflow/tasks/completed — 我的已办 +POST /workflow/tasks/{id}/complete — 完成任务 +POST /workflow/tasks/{id}/delegate — 委派任务 ``` +**RBAC:** 所有端点使用 `require_permission(&ctx, "workflow:xxx")` + --- -## Task 8: 模块注册 + 种子数据 +## Task 9: 模块注册 + 种子数据 **修改文件:** -- `crates/erp-config/src/module.rs` — 填充真实路由 -- `crates/erp-auth/src/service/seed.rs` — 添加配置模块权限(dictionary/menu/setting/numbering/theme/language) -- `crates/erp-server/src/main.rs` — 确认路由集成 +- `crates/erp-workflow/src/module.rs` — 填充真实路由(12 个端点) +- `crates/erp-auth/src/service/seed.rs` — 添加工作流权限 +- `crates/erp-server/src/main.rs` — 注册 WorkflowModule,合并路由 -**新增种子权限(17 个):** -- dictionary: create/read/update/delete/list -- menu: read/update -- setting: read/update -- numbering: create/read/update/generate -- theme: read/update -- language: read/update +**新增种子权限(8 个):** +- workflow:create, workflow:list, workflow:read, workflow:update +- workflow:publish, workflow:start, workflow:approve, workflow:delegate -**验证:** `cargo check` + `cargo test` 通过,服务器启动后 Swagger UI 可见新端点 +**验证:** `cargo check` + `cargo test` 通过 --- -## Task 9: 前端 API 层 + Settings 页面框架 +## Task 10: 前端 API 层 + 工作流页面 **创建文件(`apps/web/src/`):** -- `api/dictionaries.ts` — 字典 CRUD API -- `api/menus.ts` — 菜单 API -- `api/settings.ts` — 设置 API -- `api/numberingRules.ts` — 编号规则 API -- `pages/Settings.tsx` — Tabs 壳页面 +- `api/workflowDefinitions.ts` — 流程定义 API +- `api/workflowInstances.ts` — 流程实例 API +- `api/workflowTasks.ts` — 任务 API +- `pages/Workflow.tsx` — Tab 壳页面(流程定义 | 我的待办 | 我的已办 | 流程监控) **修改文件:** -- `App.tsx` — 替换 settings 占位组件 - -**Tabs 结构:** 数据字典 | 菜单配置 | 编号规则 | 系统参数 | 主题设置 +- `App.tsx` — 添加 workflow 路由 +- `MainLayout.tsx` — 侧边栏添加工作流菜单 --- -## Task 10: 前端设置子页面 +## Task 11: React Flow 可视化设计器 -**创建文件(`apps/web/src/pages/settings/`):** -- `DictionaryManager.tsx` — Table + 展开行显示字典项,参考 Roles.tsx -- `MenuConfig.tsx` — Tree + 编辑表单,参考 Organizations.tsx -- `NumberingRules.tsx` — Table + Modal CRUD + 生成按钮 -- `SystemSettings.tsx` — 键值编辑列表 -- `ThemeSettings.tsx` — 颜色选择器 + 表单 +**创建文件(`apps/web/src/pages/workflow/`):** +- `ProcessDesigner.tsx` — React Flow 画布 + 节点面板 + 属性面板 +- `nodes/` — 自定义节点组件(StartEvent, EndEvent, UserTask, ServiceTask, Gateway) +- `edges/` — 条件标签连线组件 +- `hooks/useFlowValidation.ts` — 流程图前端验证 -**修改文件:** -- `Settings.tsx` — 导入子组件 -- `MainLayout.tsx` — 更新设置菜单图标 +**依赖:** `@xyflow/react` npm 包 -**验证:** `pnpm dev` 启动,访问 /settings 各 tab 可正常交互 +**功能:** +- 拖拽添加节点到画布 +- 连线编辑(含条件表达式) +- 节点属性编辑面板 +- 导出为 JSON nodes/edges 格式(匹配后端 DTO) +- 流程图合法性前端验证 + +--- + +## Task 12: 流程图查看器 + 超时框架 + +**创建文件(`apps/web/src/pages/workflow/`):** +- `ProcessViewer.tsx` — 只读 React Flow 渲染,高亮当前活跃节点 +- `InstanceDetail.tsx` — 实例详情页(流程图 + 变量 + 任务历史) + +**超时框架(后端占位):** +- `crates/erp-workflow/src/engine/timeout.rs` — 超时检查接口 +- Task 表 `due_date` 字段已支持 + +**验证:** `pnpm dev` 启动,工作流设计器可拖拽节点、连线、保存 --- ## 依赖图 ``` -前置重构(RBAC→erp-core) - | Task 1(骨架) - | + | Task 2(迁移)→ Task 3(Entity)→ Task 4(DTO) - | - +----------------+----------------+ - | | - Task 5(字典+设置 Service) Task 6(菜单+编号 Service) - | | - +----------------+----------------+ - | - Task 7(Handler) - | - Task 8(集成+种子) - | - Task 9(前端API+壳) - | - Task 10(前端页面) + | + +---------------+---------------+ + | | + Task 5(BPMN 解析器) Task 6(执行引擎) + | | + +---------------+---------------+ + | + Task 7(Service) + | + Task 8(Handler) + | + Task 9(集成+种子) + | + +---------------+---------------+ + | | + Task 10(前端页面) Task 11(可视化设计器) + | | + +---------------+---------------+ + | + Task 12(查看器+超时) ``` +--- + ## 验证清单 - [ ] `cargo check` 全 workspace 通过 - [ ] `cargo test --workspace` 全部通过 - [ ] Docker 环境正常启动 - [ ] 所有迁移可正/反向执行 -- [ ] API 端点可通过 Swagger UI 测试 -- [ ] 前端 /settings 页面各 Tab 正常工作 +- [ ] 12 个工作流 API 端点可测试 +- [ ] 前端工作流设计器可拖拽节点和连线 +- [ ] 流程图保存和加载正常 - [ ] 所有代码已提交 ## 关键参考文件 | 用途 | 文件路径 | |------|----------| -| Service 模式 | `crates/erp-auth/src/service/role_service.rs` | -| Handler 模式 | `crates/erp-auth/src/handler/role_handler.rs` | -| 树构建模式 | `crates/erp-auth/src/service/org_service.rs` | -| 迁移模式 | `crates/erp-server/migration/src/m20260411_000005_create_roles.rs` | +| Service 模式 | `crates/erp-config/src/service/numbering_service.rs` | +| Handler 模式 | `crates/erp-config/src/handler/numbering_handler.rs` | | State 桥接 | `crates/erp-server/src/state.rs` | -| 复合主键 Entity | `crates/erp-auth/src/entity/role_permission.rs` | +| 模块注册 | `crates/erp-config/src/module.rs` | +| 迁移模式 | `crates/erp-server/migration/src/m20260412_000016_create_settings.rs` | +| Advisory Lock | `crates/erp-config/src/service/numbering_service.rs` (generate_number) | | 前端 Table CRUD | `apps/web/src/pages/Roles.tsx` | | 前端树形展示 | `apps/web/src/pages/Organizations.tsx` | -| RBAC 工具函数 | `crates/erp-auth/src/middleware/rbac.rs`(待迁移到 erp-core) | +| RBAC | `crates/erp-core/src/rbac.rs` |