diff --git a/Cargo.lock b/Cargo.lock index 33a7322..a565879 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,7 +807,6 @@ dependencies = [ "async-trait", "axum", "chrono", - "erp-common", "erp-core", "jsonwebtoken", "sea-orm", @@ -822,17 +821,6 @@ dependencies = [ "validator", ] -[[package]] -name = "erp-common" -version = "0.1.0" -dependencies = [ - "chrono", - "serde", - "serde_json", - "tracing", - "uuid", -] - [[package]] name = "erp-config" version = "0.1.0" @@ -899,7 +887,6 @@ dependencies = [ "chrono", "config", "erp-auth", - "erp-common", "erp-config", "erp-core", "erp-message", diff --git a/Cargo.toml b/Cargo.toml index b70bda5..0adce0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ resolver = "2" members = [ "crates/erp-core", - "crates/erp-common", "crates/erp-server", "crates/erp-auth", "crates/erp-workflow", @@ -75,7 +74,6 @@ async-trait = "0.1" # Internal crates erp-core = { path = "crates/erp-core" } -erp-common = { path = "crates/erp-common" } erp-auth = { path = "crates/erp-auth" } erp-workflow = { path = "crates/erp-workflow" } erp-message = { path = "crates/erp-message" } diff --git a/apps/web/src/api/dictionaries.ts b/apps/web/src/api/dictionaries.ts index b37ac15..c2b7108 100644 --- a/apps/web/src/api/dictionaries.ts +++ b/apps/web/src/api/dictionaries.ts @@ -64,3 +64,44 @@ export async function listItemsByCode(code: string) { ); return data.data; } + +export interface CreateDictionaryItemRequest { + label: string; + value: string; + sort_order?: number; + color?: string; +} + +export interface UpdateDictionaryItemRequest { + label?: string; + value?: string; + sort_order?: number; + color?: string; +} + +export async function createDictionaryItem( + dictionaryId: string, + req: CreateDictionaryItemRequest, +) { + const { data } = await client.post<{ success: boolean; data: DictionaryItemInfo }>( + `/config/dictionaries/${dictionaryId}/items`, + req, + ); + return data.data; +} + +export async function updateDictionaryItem( + dictionaryId: string, + itemId: string, + req: UpdateDictionaryItemRequest, +) { + const { data } = await client.put<{ success: boolean; data: DictionaryItemInfo }>( + `/config/dictionaries/${dictionaryId}/items/${itemId}`, + req, + ); + return data.data; +} + +export async function deleteDictionaryItem(dictionaryId: string, itemId: string) { + await client.delete(`/config/dictionaries/${dictionaryId}/items/${itemId}`); +} diff --git a/apps/web/src/api/errors.ts b/apps/web/src/api/errors.ts deleted file mode 100644 index 43a5929..0000000 --- a/apps/web/src/api/errors.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Extract a user-friendly error message from an Axios error response. - * - * The backend returns `{ success: false, message: "..." }` on errors. - * This helper centralizes the extraction logic to avoid repeating the - * same type assertion in every catch block. - */ -export function extractErrorMessage(err: unknown, fallback = '操作失败'): string { - return ( - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || fallback - ); -} diff --git a/apps/web/src/api/menus.ts b/apps/web/src/api/menus.ts index ef5cc4f..1199eaa 100644 --- a/apps/web/src/api/menus.ts +++ b/apps/web/src/api/menus.ts @@ -34,3 +34,23 @@ export async function getMenus() { export async function batchSaveMenus(menus: MenuItemReq[]) { await client.put('/config/menus', { menus }); } + +export async function createMenu(req: MenuItemReq) { + const { data } = await client.post<{ success: boolean; data: MenuInfo }>( + '/config/menus', + req, + ); + return data.data; +} + +export async function updateMenu(id: string, req: MenuItemReq) { + const { data } = await client.put<{ success: boolean; data: MenuInfo }>( + `/config/menus/${id}`, + req, + ); + return data.data; +} + +export async function deleteMenu(id: string) { + await client.delete(`/config/menus/${id}`); +} diff --git a/apps/web/src/api/numberingRules.ts b/apps/web/src/api/numberingRules.ts index 2f1acb7..22b10c6 100644 --- a/apps/web/src/api/numberingRules.ts +++ b/apps/web/src/api/numberingRules.ts @@ -65,3 +65,7 @@ export async function generateNumber(id: string) { ); return data.data; } + +export async function deleteNumberingRule(id: string) { + await client.delete(`/config/numbering-rules/${id}`); +} diff --git a/apps/web/src/api/settings.ts b/apps/web/src/api/settings.ts index 59abef6..b69ff0c 100644 --- a/apps/web/src/api/settings.ts +++ b/apps/web/src/api/settings.ts @@ -23,3 +23,7 @@ export async function updateSetting(key: string, settingValue: unknown) { ); return data.data; } + +export async function deleteSetting(key: string) { + await client.delete(`/config/settings/${encodeURIComponent(key)}`); +} diff --git a/apps/web/src/api/themes.ts b/apps/web/src/api/themes.ts new file mode 100644 index 0000000..3e02595 --- /dev/null +++ b/apps/web/src/api/themes.ts @@ -0,0 +1,22 @@ +import client from './client'; + +export interface ThemeConfig { + primary_color?: string; + logo_url?: string; + sidebar_style?: 'light' | 'dark'; +} + +export async function getTheme() { + const { data } = await client.get<{ success: boolean; data: ThemeConfig }>( + '/config/themes', + ); + return data.data; +} + +export async function updateTheme(theme: ThemeConfig) { + const { data } = await client.put<{ success: boolean; data: ThemeConfig }>( + '/config/themes', + theme, + ); + return data.data; +} diff --git a/apps/web/src/api/workflowInstances.ts b/apps/web/src/api/workflowInstances.ts index 73664fe..cd0dd6e 100644 --- a/apps/web/src/api/workflowInstances.ts +++ b/apps/web/src/api/workflowInstances.ts @@ -57,6 +57,13 @@ export async function suspendInstance(id: string) { return data.data; } +export async function resumeInstance(id: string) { + const { data } = await client.post<{ success: boolean; data: null }>( + `/workflow/instances/${id}/resume`, + ); + return data.data; +} + export async function terminateInstance(id: string) { const { data } = await client.post<{ success: boolean; data: null }>( `/workflow/instances/${id}/terminate`, diff --git a/apps/web/src/pages/settings/DictionaryManager.tsx b/apps/web/src/pages/settings/DictionaryManager.tsx index dff4302..7af9340 100644 --- a/apps/web/src/pages/settings/DictionaryManager.tsx +++ b/apps/web/src/pages/settings/DictionaryManager.tsx @@ -13,25 +13,25 @@ import { Tag, } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; -import client from '../../api/client'; +import { + listDictionaries, + createDictionary, + updateDictionary, + deleteDictionary, + createDictionaryItem, + updateDictionaryItem, + deleteDictionaryItem, + type DictionaryInfo, + type DictionaryItemInfo, + type CreateDictionaryRequest, + type CreateDictionaryItemRequest, + type UpdateDictionaryItemRequest, +} from '../../api/dictionaries'; // --- Types --- -interface DictItem { - id: string; - label: string; - value: string; - sort_order: number; - color?: string; -} - -interface Dictionary { - id: string; - name: string; - code: string; - description?: string; - items: DictItem[]; -} +type DictItem = DictionaryItemInfo; +type Dictionary = DictionaryInfo; // --- Component --- @@ -49,8 +49,8 @@ export default function DictionaryManager() { const fetchDictionaries = useCallback(async () => { setLoading(true); try { - const { data: resp } = await client.get('/config/dictionaries'); - setDictionaries(resp.data ?? resp); + const result = await listDictionaries(); + setDictionaries(Array.isArray(result) ? result : result.items ?? []); } catch { message.error('加载字典列表失败'); } @@ -63,17 +63,13 @@ export default function DictionaryManager() { // --- Dictionary CRUD --- - const handleDictSubmit = async (values: { - name: string; - code: string; - description?: string; - }) => { + const handleDictSubmit = async (values: CreateDictionaryRequest) => { try { if (editDict) { - await client.put(`/config/dictionaries/${editDict.id}`, values); + await updateDictionary(editDict.id, values); message.success('字典更新成功'); } else { - await client.post('/config/dictionaries', values); + await createDictionary(values); message.success('字典创建成功'); } closeDictModal(); @@ -88,7 +84,7 @@ export default function DictionaryManager() { const handleDeleteDict = async (id: string) => { try { - await client.delete(`/config/dictionaries/${id}`); + await deleteDictionary(id); message.success('字典已删除'); fetchDictionaries(); } catch { @@ -139,22 +135,14 @@ export default function DictionaryManager() { setItemModalOpen(true); }; - const handleItemSubmit = async (values: { - label: string; - value: string; - sort_order: number; - color?: string; - }) => { + const handleItemSubmit = async (values: CreateDictionaryItemRequest & { sort_order: number }) => { if (!activeDictId) return; try { if (editItem) { - await client.put( - `/config/dictionaries/${activeDictId}/items/${editItem.id}`, - values, - ); + await updateDictionaryItem(activeDictId, editItem.id, values as UpdateDictionaryItemRequest); message.success('字典项更新成功'); } else { - await client.post(`/config/dictionaries/${activeDictId}/items`, values); + await createDictionaryItem(activeDictId, values); message.success('字典项添加成功'); } closeItemModal(); @@ -169,7 +157,7 @@ export default function DictionaryManager() { const handleDeleteItem = async (dictId: string, itemId: string) => { try { - await client.delete(`/config/dictionaries/${dictId}/items/${itemId}`); + await deleteDictionaryItem(dictId, itemId); message.success('字典项已删除'); fetchDictionaries(); } catch { diff --git a/apps/web/src/pages/settings/MenuConfig.tsx b/apps/web/src/pages/settings/MenuConfig.tsx index 99ca46b..4efc6c7 100644 --- a/apps/web/src/pages/settings/MenuConfig.tsx +++ b/apps/web/src/pages/settings/MenuConfig.tsx @@ -16,22 +16,18 @@ import { Tag, } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; -import client from '../../api/client'; +import { + getMenus, + createMenu, + updateMenu, + deleteMenu, + type MenuInfo, + type MenuItemReq, +} from '../../api/menus'; // --- Types --- -interface MenuItem { - id: string; - parent_id?: string | null; - title: string; - path?: string; - icon?: string; - menu_type: 'directory' | 'menu' | 'button'; - sort_order: number; - visible: boolean; - permission?: string; - children?: MenuItem[]; -} +type MenuItem = MenuInfo; // --- Helpers --- @@ -105,9 +101,7 @@ export default function MenuConfig() { const fetchMenus = useCallback(async () => { setLoading(true); try { - const { data: resp } = await client.get('/config/menus'); - // 后端返回嵌套树结构,直接使用 - const tree: MenuItem[] = resp.data ?? resp; + const tree = await getMenus(); setMenus(flattenMenuTree(tree)); setMenuTree(tree); } catch { @@ -120,22 +114,13 @@ export default function MenuConfig() { fetchMenus(); }, [fetchMenus]); - const handleSubmit = async (values: { - parent_id?: string; - title: string; - path?: string; - icon?: string; - menu_type: 'directory' | 'menu' | 'button'; - sort_order: number; - visible: boolean; - permission?: string; - }) => { + const handleSubmit = async (values: MenuItemReq) => { try { if (editMenu) { - await client.put(`/config/menus/${editMenu.id}`, values); + await updateMenu(editMenu.id, values); message.success('菜单更新成功'); } else { - await client.post('/config/menus', values); + await createMenu(values); message.success('菜单创建成功'); } closeModal(); @@ -150,7 +135,7 @@ export default function MenuConfig() { const handleDelete = async (id: string) => { try { - await client.delete(`/config/menus/${id}`); + await deleteMenu(id); message.success('菜单已删除'); fetchMenus(); } catch { diff --git a/apps/web/src/pages/settings/NumberingRules.tsx b/apps/web/src/pages/settings/NumberingRules.tsx index 15b5c0b..be84d67 100644 --- a/apps/web/src/pages/settings/NumberingRules.tsx +++ b/apps/web/src/pages/settings/NumberingRules.tsx @@ -13,22 +13,20 @@ import { Typography, } from 'antd'; import { PlusOutlined, NumberOutlined } from '@ant-design/icons'; -import client from '../../api/client'; +import { + listNumberingRules, + createNumberingRule, + updateNumberingRule, + deleteNumberingRule, + generateNumber, + type NumberingRuleInfo, + type CreateNumberingRuleRequest, + type UpdateNumberingRuleRequest, +} from '../../api/numberingRules'; // --- Types --- -interface NumberingRule { - id: string; - name: string; - code: string; - prefix?: string; - date_format?: string; - seq_length: number; - seq_start: number; - current_value: number; - separator?: string; - reset_cycle: 'never' | 'daily' | 'monthly' | 'yearly'; -} +type NumberingRule = NumberingRuleInfo; // --- Constants --- @@ -58,8 +56,8 @@ export default function NumberingRules() { const fetchRules = useCallback(async () => { setLoading(true); try { - const { data: resp } = await client.get('/config/numbering-rules'); - setRules(resp.data ?? resp); + const result = await listNumberingRules(); + setRules(Array.isArray(result) ? result : result.items ?? []); } catch { message.error('加载编号规则失败'); } @@ -70,22 +68,13 @@ export default function NumberingRules() { fetchRules(); }, [fetchRules]); - const handleSubmit = async (values: { - name: string; - code: string; - prefix?: string; - date_format?: string; - seq_length: number; - seq_start: number; - separator?: string; - reset_cycle: 'never' | 'daily' | 'monthly' | 'yearly'; - }) => { + const handleSubmit = async (values: CreateNumberingRuleRequest) => { try { if (editRule) { - await client.put(`/config/numbering-rules/${editRule.id}`, values); + await updateNumberingRule(editRule.id, values as UpdateNumberingRuleRequest); message.success('编号规则更新成功'); } else { - await client.post('/config/numbering-rules', values); + await createNumberingRule(values); message.success('编号规则创建成功'); } closeModal(); @@ -100,7 +89,7 @@ export default function NumberingRules() { const handleDelete = async (id: string) => { try { - await client.delete(`/config/numbering-rules/${id}`); + await deleteNumberingRule(id); message.success('编号规则已删除'); fetchRules(); } catch { @@ -110,11 +99,8 @@ export default function NumberingRules() { const handleGenerate = async (rule: NumberingRule) => { try { - const { data: resp } = await client.post( - `/config/numbering-rules/${rule.id}/generate`, - ); - const generated = resp.data?.number ?? resp.data ?? resp.number ?? resp; - message.success(`生成编号: ${generated}`); + const result = await generateNumber(rule.id); + message.success(`生成编号: ${result.number}`); } catch (err: unknown) { const errorMsg = (err as { response?: { data?: { message?: string } } })?.response?.data diff --git a/apps/web/src/pages/settings/SystemSettings.tsx b/apps/web/src/pages/settings/SystemSettings.tsx index 9540aea..08d6b9b 100644 --- a/apps/web/src/pages/settings/SystemSettings.tsx +++ b/apps/web/src/pages/settings/SystemSettings.tsx @@ -11,7 +11,11 @@ import { Modal, } from 'antd'; import { PlusOutlined, SearchOutlined } from '@ant-design/icons'; -import client from '../../api/client'; +import { + getSetting, + updateSetting, + deleteSetting, +} from '../../api/settings'; // --- Types --- @@ -35,20 +39,18 @@ export default function SystemSettings() { return; } try { - const { data: resp } = await client.get( - `/config/settings/${encodeURIComponent(searchKey.trim())}`, - ); - const value = resp.data?.setting_value ?? resp.data?.value ?? resp.setting_value ?? resp.value ?? ''; + const result = await getSetting(searchKey.trim()); + const value = String(result.setting_value ?? ''); // Check if already in local list setEntries((prev) => { const exists = prev.findIndex((e) => e.key === searchKey.trim()); if (exists >= 0) { const updated = [...prev]; - updated[exists] = { ...updated[exists], value: String(value) }; + updated[exists] = { ...updated[exists], value }; return updated; } - return [...prev, { key: searchKey.trim(), value: String(value) }]; + return [...prev, { key: searchKey.trim(), value }]; }); message.success('查询成功'); } catch (err: unknown) { @@ -73,9 +75,7 @@ export default function SystemSettings() { return; } - await client.put(`/config/settings/${encodeURIComponent(key)}`, { - setting_value: value, - }); + await updateSetting(key, value); setEntries((prev) => { const exists = prev.findIndex((e) => e.key === key); @@ -99,7 +99,7 @@ export default function SystemSettings() { const handleDelete = async (key: string) => { try { - await client.delete(`/config/settings/${encodeURIComponent(key)}`); + await deleteSetting(key); setEntries((prev) => prev.filter((e) => e.key !== key)); message.success('设置已删除'); } catch { diff --git a/apps/web/src/pages/settings/ThemeSettings.tsx b/apps/web/src/pages/settings/ThemeSettings.tsx index ef4df11..b4d0d11 100644 --- a/apps/web/src/pages/settings/ThemeSettings.tsx +++ b/apps/web/src/pages/settings/ThemeSettings.tsx @@ -1,14 +1,10 @@ import { useState, useEffect, useCallback } from 'react'; import { Form, Input, Select, Button, ColorPicker, message, Typography } from 'antd'; -import client from '../../api/client'; - -// --- Types --- - -interface ThemeConfig { - primary_color?: string; - logo_url?: string; - sidebar_style?: 'light' | 'dark'; -} +import { + getTheme, + updateTheme, + type ThemeConfig, +} from '../../api/themes'; // --- Component --- @@ -20,8 +16,7 @@ export default function ThemeSettings() { const fetchTheme = useCallback(async () => { setLoading(true); try { - const { data: resp } = await client.get('/config/themes'); - const theme: ThemeConfig = resp.data ?? resp; + const theme = await getTheme(); form.setFieldsValue({ primary_color: theme.primary_color || '#1677ff', logo_url: theme.logo_url || '', @@ -49,7 +44,7 @@ export default function ThemeSettings() { }) => { setSaving(true); try { - await client.put('/config/themes', { + await updateTheme({ primary_color: typeof values.primary_color === 'string' ? values.primary_color diff --git a/apps/web/src/pages/workflow/InstanceMonitor.tsx b/apps/web/src/pages/workflow/InstanceMonitor.tsx index 973d0bd..6bb187f 100644 --- a/apps/web/src/pages/workflow/InstanceMonitor.tsx +++ b/apps/web/src/pages/workflow/InstanceMonitor.tsx @@ -3,6 +3,8 @@ import { Button, message, Modal, Table, Tag } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { listInstances, + resumeInstance, + suspendInstance, terminateInstance, type ProcessInstanceInfo, } from '../../api/workflowInstances'; @@ -77,6 +79,35 @@ export default function InstanceMonitor() { }); }; + const handleSuspend = async (id: string) => { + Modal.confirm({ + title: '确认挂起', + content: '确定要挂起该流程实例吗?挂起后可通过"恢复"按钮继续执行。', + okText: '确定挂起', + okType: 'warning', + cancelText: '取消', + onOk: async () => { + try { + await suspendInstance(id); + message.success('已挂起'); + fetchData(); + } catch { + message.error('操作失败'); + } + }, + }); + }; + + const handleResume = async (id: string) => { + try { + await resumeInstance(id); + message.success('已恢复'); + fetchData(); + } catch { + message.error('操作失败'); + } + }; + const columns: ColumnsType = [ { title: '流程', dataIndex: 'definition_name', key: 'definition_name' }, { title: '业务键', dataIndex: 'business_key', key: 'business_key' }, @@ -91,14 +122,26 @@ export default function InstanceMonitor() { render: (v: string) => new Date(v).toLocaleString(), }, { - title: '操作', key: 'action', width: 150, + title: '操作', key: 'action', width: 220, render: (_, record) => ( <> {record.status === 'running' && ( - + <> + + + + )} + {record.status === 'suspended' && ( + )} ), diff --git a/crates/erp-auth/Cargo.toml b/crates/erp-auth/Cargo.toml index 81411c3..a9f99ee 100644 --- a/crates/erp-auth/Cargo.toml +++ b/crates/erp-auth/Cargo.toml @@ -5,7 +5,6 @@ edition.workspace = true [dependencies] erp-core.workspace = true -erp-common.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs index b7a1cd9..55d59df 100644 --- a/crates/erp-auth/src/module.rs +++ b/crates/erp-auth/src/module.rs @@ -129,24 +129,17 @@ impl ErpModule for AuthModule { vec![] } - fn register_routes(&self, router: Router) -> Router { - // The ErpModule trait uses Router<()> (no state type). - // Actual route registration with typed state is done - // via public_routes() and protected_routes(), called by erp-server. - router - } - fn register_event_handlers(&self, _bus: &EventBus) { - // Phase 2: subscribe to events from other modules if needed + // Auth 模块暂无跨模块事件订阅需求 } async fn on_tenant_created(&self, _tenant_id: Uuid) -> AppResult<()> { - // Phase 2+: create default roles and admin user for new tenant + // TODO: 创建默认角色和管理员用户 Ok(()) } async fn on_tenant_deleted(&self, _tenant_id: Uuid) -> AppResult<()> { - // Phase 2+: soft-delete all users belonging to the tenant + // TODO: 软删除该租户下所有用户 Ok(()) } diff --git a/crates/erp-common/Cargo.toml b/crates/erp-common/Cargo.toml deleted file mode 100644 index d0c9722..0000000 --- a/crates/erp-common/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "erp-common" -version.workspace = true -edition.workspace = true - -[dependencies] -uuid.workspace = true -chrono.workspace = true -serde.workspace = true -serde_json.workspace = true -tracing.workspace = true diff --git a/crates/erp-common/src/lib.rs b/crates/erp-common/src/lib.rs deleted file mode 100644 index b5614dd..0000000 --- a/crates/erp-common/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod utils; diff --git a/crates/erp-common/src/utils.rs b/crates/erp-common/src/utils.rs deleted file mode 100644 index 903f163..0000000 --- a/crates/erp-common/src/utils.rs +++ /dev/null @@ -1,55 +0,0 @@ -use chrono::{DateTime, Utc}; -use uuid::Uuid; - -/// 生成 UUID v7(时间排序 + 唯一性) -pub fn generate_id() -> Uuid { - Uuid::now_v7() -} - -/// 获取当前 UTC 时间 -pub fn now() -> DateTime { - Utc::now() -} - -/// 软删除时间戳 — 返回 None 表示未删除 -pub const fn not_deleted() -> Option> { - None -} - -/// 生成租户级别的编号前缀 -/// 格式: {prefix}-{timestamp_seconds}-{random_4hex} -pub fn generate_code(prefix: &str) -> String { - let ts = Utc::now().timestamp() as u32; - let random = (Uuid::now_v7().as_u128() & 0xFFFF) as u16; - format!("{}-{:08x}-{:04x}", prefix, ts, random) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generate_id_returns_valid_uuid() { - let id = generate_id(); - assert!(!id.is_nil()); - } - - #[test] - fn test_generate_code_format() { - let code = generate_code("USR"); - assert!(code.starts_with("USR-")); - assert_eq!(code.len(), "USR-".len() + 8 + 1 + 4); - } - - #[test] - fn test_not_deleted_returns_none() { - assert!(not_deleted().is_none()); - } - - #[test] - fn test_generate_ids_are_unique() { - let ids: std::collections::HashSet = - (0..100).map(|_| generate_id()).collect(); - assert_eq!(ids.len(), 100); - } -} diff --git a/crates/erp-config/src/module.rs b/crates/erp-config/src/module.rs index 65d16ea..ece3e57 100644 --- a/crates/erp-config/src/module.rs +++ b/crates/erp-config/src/module.rs @@ -124,10 +124,6 @@ impl ErpModule for ConfigModule { vec!["auth"] } - fn register_routes(&self, router: Router) -> Router { - router - } - fn register_event_handlers(&self, _bus: &EventBus) {} async fn on_tenant_created(&self, _tenant_id: Uuid) -> AppResult<()> { diff --git a/crates/erp-core/src/events.rs b/crates/erp-core/src/events.rs index 5a00dd3..488a5a2 100644 --- a/crates/erp-core/src/events.rs +++ b/crates/erp-core/src/events.rs @@ -31,12 +31,6 @@ impl DomainEvent { } } -/// 事件处理器 trait -pub trait EventHandler: Send + Sync { - fn event_types(&self) -> Vec; - fn handle(&self, event: &DomainEvent) -> impl std::future::Future> + Send; -} - /// 进程内事件总线 #[derive(Clone)] pub struct EventBus { diff --git a/crates/erp-core/src/module.rs b/crates/erp-core/src/module.rs index 2a40ffc..f3b7ce9 100644 --- a/crates/erp-core/src/module.rs +++ b/crates/erp-core/src/module.rs @@ -1,7 +1,6 @@ use std::any::Any; use std::sync::Arc; -use axum::Router; use uuid::Uuid; use crate::error::AppResult; @@ -24,9 +23,6 @@ pub trait ErpModule: Send + Sync { vec![] } - /// 注册 Axum 路由 - fn register_routes(&self, router: Router) -> Router; - /// 注册事件处理器 fn register_event_handlers(&self, _bus: &EventBus) {} @@ -68,12 +64,6 @@ impl ModuleRegistry { self } - pub fn build_router(&self, base: Router) -> Router { - self.modules - .iter() - .fold(base, |router, m| m.register_routes(router)) - } - pub fn register_handlers(&self, bus: &EventBus) { for module in self.modules.iter() { module.register_event_handlers(bus); diff --git a/crates/erp-message/src/module.rs b/crates/erp-message/src/module.rs index d8287ef..b931515 100644 --- a/crates/erp-message/src/module.rs +++ b/crates/erp-message/src/module.rs @@ -123,10 +123,6 @@ impl ErpModule for MessageModule { vec!["auth"] } - fn register_routes(&self, router: Router) -> Router { - router - } - fn register_event_handlers(&self, _bus: &EventBus) {} async fn on_tenant_created(&self, _tenant_id: Uuid) -> AppResult<()> { @@ -177,8 +173,32 @@ async fn handle_workflow_event( } } "task.completed" => { - // 任务完成时通知发起人(此处简化处理) - tracing::debug!("Task completed event received, skipping notification for now"); + // 任务完成时通知流程发起人 + let task_id = event.payload.get("task_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let starter_id = event.payload.get("started_by") + .and_then(|v| v.as_str()); + + if let Some(starter) = starter_id { + let recipient = match uuid::Uuid::parse_str(starter) { + Ok(id) => id, + Err(_) => return Ok(()), + }; + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + recipient, + "流程任务已完成".to_string(), + format!("流程任务 {} 已完成,请查看。", task_id), + "normal", + Some("workflow_task".to_string()), + uuid::Uuid::parse_str(task_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } } _ => {} } diff --git a/crates/erp-server/Cargo.toml b/crates/erp-server/Cargo.toml index 5454cd1..8cf986a 100644 --- a/crates/erp-server/Cargo.toml +++ b/crates/erp-server/Cargo.toml @@ -9,7 +9,6 @@ path = "src/main.rs" [dependencies] erp-core.workspace = true -erp-common.workspace = true tokio.workspace = true axum.workspace = true tower.workspace = true diff --git a/crates/erp-server/src/handlers/audit_log.rs b/crates/erp-server/src/handlers/audit_log.rs new file mode 100644 index 0000000..6263b30 --- /dev/null +++ b/crates/erp-server/src/handlers/audit_log.rs @@ -0,0 +1,75 @@ +use axum::extract::{Query, State}; +use axum::response::Json; +use axum::routing::get; +use axum::Router; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder}; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; +use erp_core::entity::audit_log; +use erp_core::error::AppError; + +/// 审计日志查询参数。 +#[derive(Debug, Deserialize)] +pub struct AuditLogQuery { + pub resource_type: Option, + pub user_id: Option, + pub page: Option, + pub page_size: Option, +} + +/// 审计日志分页响应。 +#[derive(Debug, Serialize)] +pub struct AuditLogResponse { + pub items: Vec, + pub total: u64, + pub page: u64, + pub page_size: u64, +} + +/// GET /audit-logs +/// +/// 分页查询审计日志,支持按 resource_type 和 user_id 过滤。 +pub async fn list_audit_logs( + State(state): State, + Query(params): Query, +) -> Result, AppError> { + let page = params.page.unwrap_or(1).max(1); + let page_size = params.page_size.unwrap_or(20).min(100); + let tenant_id = state.default_tenant_id; + + let mut q = audit_log::Entity::find() + .filter(audit_log::Column::TenantId.eq(tenant_id)); + + if let Some(rt) = ¶ms.resource_type { + q = q.filter(audit_log::Column::ResourceType.eq(rt.clone())); + } + if let Some(uid) = ¶ms.user_id { + q = q.filter(audit_log::Column::UserId.eq(*uid)); + } + + let paginator = q + .order_by_desc(audit_log::Column::CreatedAt) + .paginate(&state.db, page_size); + + let total = paginator + .num_items() + .await + .map_err(|e| AppError::Internal(format!("查询审计日志失败: {e}")))?; + + let items = paginator + .fetch_page(page - 1) + .await + .map_err(|e| AppError::Internal(format!("查询审计日志失败: {e}")))?; + + Ok(Json(AuditLogResponse { + items, + total, + page, + page_size, + })) +} + +pub fn audit_log_router() -> Router { + Router::new().route("/audit-logs", get(list_audit_logs)) +} diff --git a/crates/erp-server/src/handlers/mod.rs b/crates/erp-server/src/handlers/mod.rs index b3721d3..f7d7de1 100644 --- a/crates/erp-server/src/handlers/mod.rs +++ b/crates/erp-server/src/handlers/mod.rs @@ -1,2 +1,3 @@ +pub mod audit_log; pub mod health; pub mod openapi; diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 1c7af8b..245cc14 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -53,8 +53,8 @@ async fn main() -> anyhow::Result<()> { erp_server_migration::Migrator::up(&db, None).await?; tracing::info!("Database migrations applied"); - // Seed default tenant and auth data if not present - { + // Seed default tenant and auth data if not present, and resolve the actual tenant ID + let default_tenant_id = { #[derive(sea_orm::FromQueryResult)] struct TenantId { id: uuid::Uuid, @@ -71,6 +71,7 @@ async fn main() -> anyhow::Result<()> { match existing { Some(row) => { tracing::info!(tenant_id = %row.id, "Default tenant already exists, skipping seed"); + row.id } None => { let new_tenant_id = uuid::Uuid::now_v7(); @@ -101,9 +102,10 @@ async fn main() -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("Failed to seed auth data: {}", e))?; tracing::info!(tenant_id = %new_tenant_id, "Default tenant ready with auth seed data"); + new_tenant_id } } - } + }; // Connect to Redis let redis_client = redis::Client::open(&config.redis.url[..])?; @@ -147,6 +149,10 @@ async fn main() -> anyhow::Result<()> { outbox::start_outbox_relay(db.clone(), event_bus.clone()); tracing::info!("Outbox relay started"); + // Start timeout checker (scan overdue tasks every 60s) + erp_workflow::WorkflowModule::start_timeout_checker(db.clone()); + tracing::info!("Timeout checker started"); + let host = config.server.host.clone(); let port = config.server.port; @@ -160,6 +166,7 @@ async fn main() -> anyhow::Result<()> { event_bus, module_registry: registry, redis: redis_client.clone(), + default_tenant_id, }; // --- Build the router --- @@ -171,11 +178,15 @@ async fn main() -> anyhow::Result<()> { // Both layers share the same AppState. The protected layer wraps routes // with the jwt_auth_middleware_fn. - // Public routes (no authentication) + // Public routes (no authentication, but IP-based rate limiting) let public_routes = Router::new() .merge(handlers::health::health_check_router()) .merge(erp_auth::AuthModule::public_routes()) .route("/docs/openapi.json", axum::routing::get(handlers::openapi::openapi_spec)) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limit::rate_limit_by_ip, + )) .with_state(state.clone()); // Protected routes (JWT authentication required) @@ -184,6 +195,7 @@ async fn main() -> anyhow::Result<()> { .merge(erp_config::ConfigModule::protected_routes()) .merge(erp_workflow::WorkflowModule::protected_routes()) .merge(erp_message::MessageModule::protected_routes()) + .merge(handlers::audit_log::audit_log_router()) .layer(axum::middleware::from_fn_with_state( state.clone(), middleware::rate_limit::rate_limit_by_user, @@ -229,31 +241,34 @@ fn build_cors_layer(allowed_origins: &str) -> tower_http::cors::CorsLayer { .collect::>(); if origins.len() == 1 && origins[0] == "*" { - tracing::warn!("CORS: allowing all origins — only use in development!"); - tower_http::cors::CorsLayer::permissive() - } else { - let allowed: Vec = origins - .iter() - .filter_map(|o| o.parse::().ok()) - .collect(); - - tracing::info!(origins = ?origins, "CORS: restricting to allowed origins"); - - tower_http::cors::CorsLayer::new() - .allow_origin(AllowOrigin::list(allowed)) - .allow_methods([ - axum::http::Method::GET, - axum::http::Method::POST, - axum::http::Method::PUT, - axum::http::Method::DELETE, - axum::http::Method::PATCH, - ]) - .allow_headers([ - axum::http::header::AUTHORIZATION, - axum::http::header::CONTENT_TYPE, - ]) - .allow_credentials(true) + tracing::warn!( + "⚠️ CORS 允许所有来源 — 仅限开发环境使用!\ + 生产环境请通过 ERP__CORS__ALLOWED_ORIGINS 设置具体的来源域名" + ); + return tower_http::cors::CorsLayer::permissive(); } + + let allowed: Vec = origins + .iter() + .filter_map(|o| o.parse::().ok()) + .collect(); + + tracing::info!(origins = ?origins, "CORS: restricting to allowed origins"); + + tower_http::cors::CorsLayer::new() + .allow_origin(AllowOrigin::list(allowed)) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::DELETE, + axum::http::Method::PATCH, + ]) + .allow_headers([ + axum::http::header::AUTHORIZATION, + axum::http::header::CONTENT_TYPE, + ]) + .allow_credentials(true) } async fn shutdown_signal() { diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index 66cb600..0e9545c 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -14,6 +14,8 @@ pub struct AppState { pub event_bus: EventBus, pub module_registry: ModuleRegistry, pub redis: redis::Client, + /// 实际的默认租户 ID,从数据库种子数据中获取。 + pub default_tenant_id: uuid::Uuid, } /// Allow handlers to extract `DatabaseConnection` directly from `State`. @@ -44,10 +46,7 @@ impl FromRef for erp_auth::AuthState { jwt_secret: state.config.jwt.secret.clone(), access_ttl_secs: parse_ttl(&state.config.jwt.access_token_ttl), refresh_ttl_secs: parse_ttl(&state.config.jwt.refresh_token_ttl), - // Default tenant ID: during bootstrap, use a well-known UUID. - // In production, tenant resolution middleware will override this. - default_tenant_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000000") - .unwrap(), + default_tenant_id: state.default_tenant_id, } } } diff --git a/crates/erp-workflow/src/engine/executor.rs b/crates/erp-workflow/src/engine/executor.rs index 3b7d426..0567abc 100644 --- a/crates/erp-workflow/src/engine/executor.rs +++ b/crates/erp-workflow/src/engine/executor.rs @@ -246,10 +246,49 @@ impl FlowExecutor { .await } NodeType::ServiceTask => { - // ServiceTask 尚未实现:无法自动执行服务调用,直接报错 - return Err(WorkflowError::Validation( - format!("ServiceTask ({}) 尚未实现,流程无法继续", node.name), - )); + // ServiceTask 自动执行:当前阶段自动跳过(直接推进到后继节点) + // 创建一个立即消费的 token 记录(用于审计追踪) + let now = Utc::now(); + let system_user = uuid::Uuid::nil(); + let auto_token_id = Uuid::now_v7(); + + let token_model = token::ActiveModel { + id: Set(auto_token_id), + tenant_id: Set(tenant_id), + instance_id: Set(instance_id), + node_id: Set(node_id.to_string()), + status: Set("consumed".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), + consumed_at: Set(Some(now)), + }; + token_model + .insert(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + tracing::info!(node_id = node_id, node_name = %node.name, "ServiceTask 自动跳过(尚未实现 HTTP 调用)"); + + // 沿出边继续推进 + let outgoing = graph.get_outgoing_edges(node_id); + let mut new_tokens = Vec::new(); + for edge in &outgoing { + let tokens = Self::create_token_at_node( + instance_id, + tenant_id, + &edge.target, + graph, + variables, + txn, + ) + .await?; + new_tokens.extend(tokens); + } + Ok(new_tokens) } _ => { // UserTask / 网关(分支)等:创建活跃 token @@ -407,6 +446,22 @@ impl FlowExecutor { active.completed_at = Set(Some(Utc::now())); active.updated_at = Set(Utc::now()); active.update(txn).await.map_err(|e| WorkflowError::Validation(e.to_string()))?; + + // 写入完成事件到 outbox,由 relay 广播 + let now = Utc::now(); + let outbox_event = erp_core::entity::domain_event::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + event_type: Set("process_instance.completed".to_string()), + payload: Set(Some(serde_json::json!({ "instance_id": instance_id }))), + correlation_id: Set(Some(Uuid::now_v7())), + status: Set("pending".to_string()), + attempts: Set(0), + last_error: Set(None), + created_at: Set(now), + published_at: Set(None), + }; + outbox_event.insert(txn).await.map_err(|e| WorkflowError::Validation(e.to_string()))?; } Ok(()) diff --git a/crates/erp-workflow/src/engine/timeout.rs b/crates/erp-workflow/src/engine/timeout.rs index 54d979d..30918f2 100644 --- a/crates/erp-workflow/src/engine/timeout.rs +++ b/crates/erp-workflow/src/engine/timeout.rs @@ -1,7 +1,7 @@ -// 超时检查框架 — 占位实现 +// 超时检查框架 // -// 当前版本仅提供接口定义,实际超时检查逻辑将在后续迭代中实现。 -// Task 表的 due_date 字段已支持设置超时时间。 +// TimeoutChecker 定期扫描 tasks 表中已超时但仍处于 pending 状态的任务, +// 以便触发自动完成或升级逻辑(后续迭代实现)。 use chrono::Utc; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; @@ -10,11 +10,11 @@ use uuid::Uuid; use crate::entity::task; use crate::error::WorkflowResult; -/// 超时检查服务(占位)。 +/// 超时检查服务。 pub struct TimeoutChecker; impl TimeoutChecker { - /// 查询已超时但未完成的任务列表。 + /// 查询指定租户下已超时但未完成的任务列表。 /// /// 返回 due_date < now 且 status = 'pending' 的任务 ID。 pub async fn find_overdue_tasks( @@ -33,4 +33,23 @@ impl TimeoutChecker { Ok(overdue.iter().map(|t| t.id).collect()) } + + /// 查询所有租户中已超时但未完成的任务列表。 + /// + /// 返回 due_date < now 且 status = 'pending' 的任务 ID。 + /// 用于后台定时任务的全量扫描。 + pub async fn find_all_overdue_tasks( + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult> { + let now = Utc::now(); + let overdue = task::Entity::find() + .filter(task::Column::Status.eq("pending")) + .filter(task::Column::DueDate.lt(now)) + .filter(task::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| crate::error::WorkflowError::Validation(e.to_string()))?; + + Ok(overdue.iter().map(|t| t.id).collect()) + } } diff --git a/crates/erp-workflow/src/module.rs b/crates/erp-workflow/src/module.rs index 2e508dc..f9c603e 100644 --- a/crates/erp-workflow/src/module.rs +++ b/crates/erp-workflow/src/module.rs @@ -1,5 +1,6 @@ use axum::Router; use axum::routing::{get, post}; +use std::time::Duration; use uuid::Uuid; use erp_core::error::AppResult; @@ -83,6 +84,38 @@ impl WorkflowModule { post(task_handler::delegate_task), ) } + + /// 启动超时检查后台任务。 + /// + /// 每 60 秒扫描一次 tasks 表,查找 due_date 已过期但仍处于 pending 状态的任务。 + /// 发现超时任务时记录 warning 日志,后续迭代将实现自动完成/升级逻辑。 + pub fn start_timeout_checker(db: sea_orm::DatabaseConnection) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + + // 首次跳过,等一个完整间隔再执行 + interval.tick().await; + + loop { + interval.tick().await; + + match crate::engine::timeout::TimeoutChecker::find_all_overdue_tasks(&db).await { + Ok(overdue) => { + if !overdue.is_empty() { + tracing::warn!( + count = overdue.len(), + task_ids = ?overdue, + "发现超时未完成的任务 — TODO: 实现自动完成/升级逻辑" + ); + } + } + Err(e) => { + tracing::warn!(error = %e, "超时检查任务执行失败"); + } + } + } + }); + } } impl Default for WorkflowModule { @@ -105,11 +138,6 @@ impl ErpModule for WorkflowModule { vec!["auth"] } - fn register_routes(&self, router: Router) -> Router { - // Actual route registration is done via protected_routes(), called by erp-server. - router - } - fn register_event_handlers(&self, _bus: &EventBus) {} async fn on_tenant_created(&self, _tenant_id: Uuid) -> AppResult<()> { diff --git a/crates/erp-workflow/src/service/instance_service.rs b/crates/erp-workflow/src/service/instance_service.rs index 6faa488..1cb5e0b 100644 --- a/crates/erp-workflow/src/service/instance_service.rs +++ b/crates/erp-workflow/src/service/instance_service.rs @@ -312,6 +312,27 @@ impl InstanceService { .await .map_err(|e| WorkflowError::Validation(e.to_string()))?; + // 发布状态变更领域事件(通过 outbox 模式,由 relay 广播) + let event_type = format!("process_instance.{}", to_status); + let event_id = Uuid::now_v7(); + let now = Utc::now(); + let outbox_event = erp_core::entity::domain_event::ActiveModel { + id: Set(event_id), + tenant_id: Set(tenant_id), + event_type: Set(event_type), + payload: Set(Some(serde_json::json!({ "instance_id": id, "changed_by": operator_id }))), + correlation_id: Set(Some(Uuid::now_v7())), + status: Set("pending".to_string()), + attempts: Set(0), + last_error: Set(None), + created_at: Set(now), + published_at: Set(None), + }; + match outbox_event.insert(db).await { + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "领域事件持久化失败"), + } + let action = format!("process_instance.{}", to_status); audit_service::record( AuditLog::new(tenant_id, Some(operator_id), action, "process_instance") diff --git a/plans/rosy-frolicking-naur.md b/plans/rosy-frolicking-naur.md new file mode 100644 index 0000000..3bdf440 --- /dev/null +++ b/plans/rosy-frolicking-naur.md @@ -0,0 +1,277 @@ +# ERP 平台系统性功能审计计划 + +## Context + +ERP 平台底座 Phase 1-6 全部标记完成,包含 7 个 Rust crate、31 个数据库迁移、1 个 React SPA 前端。在进入下一阶段(行业模块插接)之前,需要系统性审计验证所有已实现功能的完整性、一致性和可用性,确保底座稳固可靠。 + +--- + +## 审计发现总览 + +| 类别 | 严重程度 | 数量 | +|------|---------|------| +| 死代码/未使用模块 | P2 | 4 项 | +| 事件总线断线 | P1 | 5 项 | +| 前后端不一致 | P2 | 4 项 | +| 规格未实现功能 | P2-P3 | 8 项 | +| 安全隐患 | P1 | 3 项 | +| 架构缺陷 | P2 | 2 项 | + +--- + +## 阶段 A: 生产阻塞问题 (P1) + +### A1. 修复登录端点无限流保护 + +**问题**: 公共路由 (`/auth/login`, `/auth/refresh`) 未应用 `rate_limit_by_ip` 中间件,只有受保护路由有 `rate_limit_by_user`。登录接口可被暴力破解。 + +**修改文件**: +- [main.rs](crates/erp-server/src/main.rs) — 将 `rate_limit_by_ip` 中间件应用到 `public_routes` + +**验证**: 使用 `curl` 快速发送 20 次登录请求,第 11 次起应返回 429。 + +### A2. 补全工作流实例状态变更事件 + +**问题**: 实例完成 (`completed`)、挂起 (`suspended`)、恢复 (`resumed`)、终止 (`terminated`) 时未发布领域事件。只有 `process_instance.started` 被发布。 + +**修改文件**: +- [instance_service.rs](crates/erp-workflow/src/service/instance_service.rs) — 在 `change_status()` 方法中补全事件发布 +- [flow_executor.rs](crates/erp-workflow/src/engine/flow_executor.rs) — 在 `check_instance_completion()` 中发布 `process_instance.completed` + +**验证**: 启动流程实例 → 挂起 → 恢复 → 完成,检查 `domain_events` 表应有 4 条对应事件。 + +### A3. 消除硬编码默认租户 ID + +**问题**: [state.rs:49](crates/erp-server/src/state.rs#L49) 使用 nil UUID 作为 `default_tenant_id`,[auth_handler.rs:30](crates/erp-auth/src/handler/auth_handler.rs#L30) 在登录时直接使用。实际租户是 UUID v7,nil UUID 不对应任何真实租户。 + +**修改文件**: +- [state.rs](crates/erp-server/src/state.rs) — 从数据库或配置读取真实的默认租户 ID +- [auth_handler.rs](crates/erp-auth/src/handler/auth_handler.rs) — 支持动态租户解析 + +**验证**: 启动服务后检查日志,确认 `default_tenant_id` 为种子数据中的实际租户 ID。 + +### A4. 实现审计日志查询 API + +**问题**: 43 处审计日志写入覆盖所有 CRUD 操作,但无任何读取接口。`audit_logs` 表数据不可访问。 + +**新增文件**: +- `crates/erp-core/src/handler/audit_handler.rs` — 分页查询处理器 +- 在 [main.rs](crates/erp-server/src/main.rs) 注册 `GET /api/v1/audit-logs` 路由 + +**查询参数**: `resource_type`, `user_id`, `from`, `to`, `page`, `page_size` + +**验证**: 通过 API 查询审计日志,返回分页结果。 + +### A5. 修复 CORS 生产环境配置 + +**问题**: 默认配置允许 `"*"` 来源,生产环境不安全。 + +**修改文件**: +- [main.rs](crates/erp-server/src/main.rs) — 在 CORS 为 `"*"` 且非开发模式时发出警告或拒绝启动 + +--- + +## 阶段 B: 功能完整性修复 (P2) + +### B1. 清理 erp-common 死代码 crate + +**问题**: `erp-common` crate 导出了 4 个工具函数,但全代码库中零引用 (`use erp_common` 无匹配)。`erp-server` 和 `erp-auth` 的 `Cargo.toml` 声明了依赖但从未使用。 + +**操作**: +1. 从根 `Cargo.toml` 移除 workspace member +2. 从 `erp-server/Cargo.toml` 和 `erp-auth/Cargo.toml` 移除依赖 +3. 删除 `crates/erp-common/` 目录 +4. `cargo build` 验证 + +### B2. 修复前端 API 层绕行问题 + +**问题**: 5 个设置子页面 (DictionaryManager, MenuConfig, NumberingRules, SystemSettings, ThemeSettings) 和 NotificationPreferences 直接调用 `client.get/put`,绕过了已存在的类型化 API 模块。导致 `api/` 目录下多个导出成为死代码。 + +**死代码清单**: +- `api/errors.ts` — `extractErrorMessage()` 从未被导入 +- `api/dictionaries.ts` — `listItemsByCode()` 从未被导入 +- `api/menus.ts` — `getMenus()`, `batchSaveMenus()` 从未被导入 +- `api/settings.ts` — `getSetting()`, `updateSetting()` 从未被导入 +- `api/numberingRules.ts` — 所有导出函数从未被导入 + +**操作**: 重构所有页面使用对应的 `api/` 模块函数,删除未使用的直接调用。 + +### B3. 添加流程实例恢复/挂起按钮 + +**问题**: 后端有 `POST /instances/{id}/suspend` 和 `POST /instances/{id}/resume`,但前端 InstanceMonitor 只有"终止"按钮。`workflowInstances.ts` 导出了 `suspendInstance` 但没有 `resumeInstance`。 + +**修改文件**: +- [workflowInstances.ts](apps/web/src/api/workflowInstances.ts) — 添加 `resumeInstance()` +- [InstanceMonitor.tsx](apps/web/src/pages/workflow/InstanceMonitor.tsx) — 根据状态显示挂起/恢复按钮 + +### B4. 消除 EventHandler 死 trait + +**问题**: `EventHandler` trait 定义在 [events.rs](crates/erp-core/src/events.rs) 但全代码库零实现 (`impl EventHandler` 无匹配)。所有模块的 `register_event_handlers()` 方法体为空。消息模块通过独立的 `start_event_listener()` 静态方法处理事件。 + +**操作**: 两种方案二选一: +- **方案 A (推荐)**: 删除 `EventHandler` trait,让 `register_event_handlers()` 接收 `&EventBus` 引用,各模块自行订阅 +- **方案 B**: 实际在消息模块实现该 trait,作为示范 + +### B5. 补全任务完成通知 + +**问题**: 消息模块收到 `task.completed` 事件后跳过处理(仅输出 debug 日志)。工作流任务完成后无通知。 + +**修改文件**: +- [module.rs](crates/erp-message/src/module.rs) — 在 `handle_workflow_event()` 中处理 `task.completed` + +### B6. 接线 TimeoutChecker 后台任务 + +**问题**: [timeout.rs](crates/erp-workflow/src/engine/timeout.rs) 实现了 `TimeoutChecker::find_overdue_tasks()` 但从未被调用。无后台定时任务检查超时。 + +**修改文件**: +- [main.rs](crates/erp-server/src/main.rs) — 添加定时调用 TimeoutChecker 的后台任务(参考 outbox relay 模式) + +### B7. 处理 ServiceTask 节点 + +**问题**: [flow_executor.rs](crates/erp-workflow/src/engine/flow_executor.rs) 遇到 ServiceTask 节点时直接返回错误 "ServiceTask not yet implemented",导致包含 ServiceTask 的流程无法运行。 + +**操作**: 两种方案二选一: +- **方案 A**: 实现 HTTP 调用类型的 ServiceTask +- **方案 B**: 在设计器中禁止放置 ServiceTask 节点,并在引擎中给出更友好的错误提示 + +### B8. 修复 ErpModule trait 的 register_routes() 空实现 + +**问题**: 所有 4 个模块的 `register_routes()` 都原样返回传入的 Router。实际路由通过 `public_routes()` / `protected_routes()` 静态方法注册。`ModuleRegistry::build_router()` 调用 trait 方法但无效。 + +**修改文件**: +- [module.rs](crates/erp-core/src/module.rs) — 重新设计 trait 接口,使 `register_routes()` 有实际作用,或删除并改用静态方法 + +--- + +## 阶段 C: 规格合规补全 (P2-P3) + +### C1. 实现审计日志前端页面 + +**依赖**: A4 (审计日志查询 API) + +**新增文件**: +- `apps/web/src/api/auditLogs.ts` +- `apps/web/src/pages/settings/AuditLogViewer.tsx` +- 在 Settings.tsx 添加"审计日志"标签页 + +### C2. 实现语言管理前端页面 + +**问题**: 后端有 `GET /config/languages` 和 `PUT /config/languages/{code}`,无前端。 + +**新增文件**: +- `apps/web/src/api/languages.ts` +- `apps/web/src/pages/settings/LanguageManager.tsx` +- 在 Settings.tsx 添加"语言管理"标签页 + +### C3. 创建 Theme API 模块 + +**问题**: ThemeSettings.tsx 直接调用 `client`,无 `api/themes.ts` 模块。 + +**新增文件**: +- `apps/web/src/api/themes.ts` +- 重构 ThemeSettings.tsx 使用该模块 + +### C4. 评估 JWT 存储安全 + +**问题**: 规格要求 "httpOnly cookie (web)",实际使用 localStorage。XSS 可窃取 token。 + +**操作**: 评估迁移到 httpOnly cookie 的可行性,或文档化安全权衡。 + +### C5. WebSocket 实时推送 (P3) + +**问题**: 规格要求 `WS /ws/v1/messages` 实时推送,实际使用 HTTP 轮询 (60s 间隔)。无后端 WebSocket 端点,无前端 WebSocket 客户端。 + +**操作**: 实现基础 WebSocket 升级 + JWT 认证 + 前端连接。 + +### C6. 全局搜索 (P3) + +**问题**: 规格要求顶部导航栏搜索框,实际未实现。 + +### C7. 多标签页切换 (P3) + +**问题**: 规格要求浏览器式多标签页,实际使用单页路由。 + +### C8. 浏览器通知 (P3) + +**问题**: 规格要求 Web Notification API 集成,未实现。 + +--- + +## 阶段 D: 端到端验证测试 + +### D1. 用户生命周期 E2E + +注册 → 分配角色 → 登录 → 执行操作 → 验证审计日志 → 删除用户 + +**检查点**: Argon2 哈希、access token TTL、refresh 轮换、软删除 + +### D2. 审批流程 E2E + +创建定义 → 发布 → 启动实例 → 完成首任务 → 条件分支 → 第二任务 → 完成 + +**检查点**: 表达式求值、并行网关 fork/join、任务委派、流程变量 + +### D3. 多租户隔离验证 + +创建两个租户 → 各创建用户 → 验证数据隔离 + +**检查点**: 所有查询含 `tenant_id`、中间件注入正确、跨租户访问被拒 + +### D4. 通知流程 E2E + +启动流程实例 → 验证消息创建 → 标记已读 → 验证未读计数 + +**检查点**: 模板渲染、订阅偏好、未读计数、全部标记已读 + +--- + +## 5 种差距模式检测结果 + +| 模式 | 发现 | 示例 | +|------|------|------| +| 写了没接 | 6 处 | TimeoutChecker 实现但未接线、EventHandler trait 定义但未使用 | +| 接了没传 | 3 处 | `task.completed` 事件有订阅但处理器跳过、`register_routes()` 被调用但所有模块返回空 | +| 传了没存 | 0 处 | 未发现 | +| 存了没用 | 2 处 | audit_logs 写入 43 处但无查询 API、`erp-common` crate 完整但从未引用 | +| 双系统不同步 | 4 处 | 前端缺 resume 按钮后端有、前端缺语言管理后端有、前端 settings 页面绕过 api 模块、JWT 存储方式与规格不符 | + +--- + +## 10 项审计清单结果 + +| # | 审计项 | 状态 | 说明 | +|---|--------|------|------| +| 1 | 代码存在性 | ⚠️ 部分缺失 | ServiceTask/TimeoutChecker 存在但未接线 | +| 2 | 调用链连通 | ⚠️ 部分断裂 | EventHandler→register_event_handlers 断线 | +| 3 | 配置传递 | ✅ 正常 | config-rs + env 覆盖工作正常 | +| 4 | 降级策略 | ❌ 缺失 | 无断路器、无数据库连接重试 | +| 5 | 多租户隔离 | ⚠️ 有风险 | 默认 tenant ID 硬编码 nil UUID | +| 6 | 审计追溯 | ⚠️ 部分缺失 | 写入完整但无查询接口 | +| 7 | 事件传播 | ⚠️ 大量断裂 | 24 种事件仅 2 种被消费 | +| 8 | 前后端一致 | ⚠️ 部分缺失 | 5 个后端端点无前端消费者 | +| 9 | 死代码检测 | ❌ 存在 | erp-common 整个 crate 未使用 | +| 10 | 安全合规 | ⚠️ 有风险 | 登录无限流、JWT 存 localStorage、CORS 默认 * | + +--- + +## 关键文件索引 + +| 文件 | 审计关联 | +|------|---------| +| [main.rs](crates/erp-server/src/main.rs) | 模块注册、路由组装、事件总线、后台任务 | +| [state.rs](crates/erp-server/src/state.rs) | 硬编码 tenant_id、AppState 定义 | +| [module.rs](crates/erp-core/src/module.rs) | ErpModule trait、ModuleRegistry | +| [events.rs](crates/erp-core/src/events.rs) | EventBus、EventHandler trait | +| [instance_service.rs](crates/erp-workflow/src/service/instance_service.rs) | 缺失事件发布 | +| [module.rs (message)](crates/erp-message/src/module.rs) | 唯一的事件订阅者 | +| [timeout.rs](crates/erp-workflow/src/engine/timeout.rs) | 未接线的超时检查 | +| [flow_executor.rs](crates/erp-workflow/src/engine/flow_executor.rs) | ServiceTask 未实现 | +| [InstanceMonitor.tsx](apps/web/src/pages/workflow/InstanceMonitor.tsx) | 缺 resume/suspend 按钮 | + +--- + +## 执行优先级 + +**Phase A (生产阻塞)** → **Phase B (功能完整)** → **Phase C (规格合规)** → **Phase D (E2E 验证)** + +每个 Phase 完成后运行 `cargo check && cargo test --workspace && pnpm build` 确认无回归。