feat: systematic functional audit — fix 18 issues across Phase A/B

Phase A (P1 production blockers):
- A1: Apply IP rate limiting to public routes (login/refresh)
- A2: Publish domain events for workflow instance state transitions
  (completed/suspended/resumed/terminated) via outbox pattern
- A3: Replace hardcoded nil UUID default tenant with dynamic DB lookup
- A4: Add GET /api/v1/audit-logs query endpoint with pagination
- A5: Enhance CORS wildcard warning for production environments

Phase B (P2 functional gaps):
- B1: Remove dead erp-common crate (zero references in codebase)
- B2: Refactor 5 settings pages to use typed API modules instead of
  direct client calls; create api/themes.ts; delete dead errors.ts
- B3: Add resume/suspend buttons to InstanceMonitor page
- B4: Remove unused EventHandler trait from erp-core
- B5: Handle task.completed events in message module (send notifications)
- B6: Wire TimeoutChecker as 60s background task
- B7: Auto-skip ServiceTask nodes instead of crashing the process
- B8: Remove empty register_routes() from ErpModule trait and modules
This commit is contained in:
iven
2026-04-12 15:22:28 +08:00
parent 685df5e458
commit 14f431efff
34 changed files with 785 additions and 304 deletions

View File

@@ -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}`);
}

View File

@@ -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
);
}

View File

@@ -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}`);
}

View File

@@ -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}`);
}

View File

@@ -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)}`);
}

View File

@@ -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;
}

View File

@@ -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`,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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<ProcessInstanceInfo> = [
{ 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) => (
<>
<Button size="small" onClick={() => handleViewFlow(record)} style={{ marginRight: 8 }}>
</Button>
{record.status === 'running' && (
<Button size="small" danger onClick={() => handleTerminate(record.id)}></Button>
<>
<Button size="small" onClick={() => handleSuspend(record.id)} style={{ marginRight: 8 }}>
</Button>
<Button size="small" danger onClick={() => handleTerminate(record.id)}>
</Button>
</>
)}
{record.status === 'suspended' && (
<Button size="small" type="primary" onClick={() => handleResume(record.id)}>
</Button>
)}
</>
),