chore: apply cargo fmt across workspace and update docs
- Run cargo fmt on all Rust crates for consistent formatting - Update CLAUDE.md with WASM plugin commands and dev.ps1 instructions - Update wiki: add WASM plugin architecture, rewrite dev environment docs - Minor frontend cleanup (unused imports)
This commit is contained in:
18
CLAUDE.md
18
CLAUDE.md
@@ -372,6 +372,17 @@ cd apps/web && pnpm build # 构建生产版本
|
||||
|
||||
# === 数据库 ===
|
||||
docker exec -it erp-postgres psql -U erp # 连接数据库
|
||||
|
||||
# === WASM 插件 ===
|
||||
cargo build -p erp-plugin-test-sample --target wasm32-unknown-unknown --release # 编译测试插件
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_test_sample.wasm -o target/erp_plugin_test_sample.component.wasm # 转为 Component
|
||||
cargo test -p erp-plugin-prototype # 运行插件集成测试
|
||||
|
||||
# === 一键启动 (PowerShell) ===
|
||||
.\dev.ps1 # 启动前后端(自动清理端口占用)
|
||||
.\dev.ps1 -Stop # 停止前后端
|
||||
.\dev.ps1 -Restart # 重启前后端
|
||||
.\dev.ps1 -Status # 查看端口状态
|
||||
```
|
||||
|
||||
---
|
||||
@@ -402,6 +413,7 @@ docker exec -it erp-postgres psql -U erp # 连接数据库
|
||||
| `message` | erp-message |
|
||||
| `config` | erp-config |
|
||||
| `server` | erp-server |
|
||||
| `plugin` | erp-plugin-prototype / erp-plugin-test-sample |
|
||||
| `web` | Web 前端 |
|
||||
| `ui` | React 组件 |
|
||||
| `db` | 数据库迁移 |
|
||||
@@ -425,6 +437,8 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
|------|------|
|
||||
| `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` | 平台底座实施计划 |
|
||||
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | WASM 插件原型验证计划 |
|
||||
|
||||
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
|
||||
|
||||
@@ -445,6 +459,7 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| Phase 4 | 工作流引擎 (Workflow) | ✅ 完成 |
|
||||
| Phase 5 | 消息中心 (Message) | ✅ 完成 |
|
||||
| Phase 6 | 整合与打磨 | ✅ 完成 |
|
||||
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
|
||||
|
||||
### 已实现模块
|
||||
|
||||
@@ -457,6 +472,8 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
|
||||
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
|
||||
| erp-config | 系统配置 (字典/菜单/设置/编号规则/主题) | ✅ 完成 |
|
||||
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
|
||||
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
|
||||
|
||||
<!-- ARCH-SNAPSHOT-END -->
|
||||
|
||||
@@ -484,5 +501,6 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步
|
||||
- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
||||
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
||||
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component
|
||||
|
||||
<!-- ANTI-PATTERN-END -->
|
||||
|
||||
1594
Cargo.lock
generated
1594
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ members = [
|
||||
"crates/erp-message",
|
||||
"crates/erp-config",
|
||||
"crates/erp-server/migration",
|
||||
"crates/erp-plugin-prototype",
|
||||
"crates/erp-plugin-test-sample",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface UserInfo {
|
||||
avatar_url?: string;
|
||||
status: string;
|
||||
roles: RoleInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface OrganizationInfo {
|
||||
level: number;
|
||||
sort_order: number;
|
||||
children: OrganizationInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateOrganizationRequest {
|
||||
@@ -24,6 +25,7 @@ export interface UpdateOrganizationRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Department types ---
|
||||
@@ -38,6 +40,7 @@ export interface DepartmentInfo {
|
||||
path?: string;
|
||||
sort_order: number;
|
||||
children: DepartmentInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateDepartmentRequest {
|
||||
@@ -53,6 +56,7 @@ export interface UpdateDepartmentRequest {
|
||||
code?: string;
|
||||
manager_id?: string;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Position types ---
|
||||
@@ -64,6 +68,7 @@ export interface PositionInfo {
|
||||
code?: string;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
@@ -78,6 +83,7 @@ export interface UpdatePositionRequest {
|
||||
code?: string;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Organization API ---
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface RoleInfo {
|
||||
code: string;
|
||||
description?: string;
|
||||
is_system: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
@@ -27,6 +28,7 @@ export interface CreateRoleRequest {
|
||||
export interface UpdateRoleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listRoles(page = 1, pageSize = 20) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface UpdateUserRequest {
|
||||
phone?: string;
|
||||
display_name?: string;
|
||||
status?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listUsers(page = 1, pageSize = 20, search = '') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Badge, List, Popover, Button, Empty, Typography, Space, theme } from 'antd';
|
||||
import { BellOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { Badge, List, Popover, Button, Empty, Typography, theme } from 'antd';
|
||||
import { BellOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMessageStore } from '../stores/message';
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { token } = theme.useToken();
|
||||
theme.useToken();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname || '/';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, Input, Button, message, Divider } from 'antd';
|
||||
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
Table,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
Card,
|
||||
Empty,
|
||||
Tag,
|
||||
theme,
|
||||
@@ -52,7 +50,7 @@ export default function Organizations() {
|
||||
// --- Org tree state ---
|
||||
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
|
||||
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [, setLoading] = useState(false);
|
||||
|
||||
// --- Department tree state ---
|
||||
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
|
||||
@@ -144,6 +142,7 @@ export default function Organizations() {
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
sort_order: values.sort_order,
|
||||
version: editOrg.version,
|
||||
});
|
||||
message.success('组织更新成功');
|
||||
} else {
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function Roles() {
|
||||
}) => {
|
||||
try {
|
||||
if (editRole) {
|
||||
await updateRole(editRole.id, values);
|
||||
await updateRole(editRole.id, { ...values, version: editRole.version });
|
||||
message.success('角色更新成功');
|
||||
} else {
|
||||
await createRole(values);
|
||||
|
||||
@@ -107,6 +107,7 @@ export default function Users() {
|
||||
display_name: values.display_name,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
version: editUser.version,
|
||||
};
|
||||
await updateUser(editUser.id, req);
|
||||
message.success('用户更新成功');
|
||||
@@ -144,7 +145,9 @@ export default function Users() {
|
||||
|
||||
const handleToggleStatus = async (id: string, status: string) => {
|
||||
try {
|
||||
await updateUser(id, { status });
|
||||
const user = users.find(u => u.id === id);
|
||||
if (!user) return;
|
||||
await updateUser(id, { status, version: user.version });
|
||||
message.success(status === 'disabled' ? '用户已禁用' : '用户已启用');
|
||||
fetchUsers();
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Tabs, theme } from 'antd';
|
||||
import { Tabs } from 'antd';
|
||||
import { PartitionOutlined, FileSearchOutlined, CheckSquareOutlined, MonitorOutlined } from '@ant-design/icons';
|
||||
import ProcessDefinitions from './workflow/ProcessDefinitions';
|
||||
import PendingTasks from './workflow/PendingTasks';
|
||||
@@ -8,8 +8,6 @@ import InstanceMonitor from './workflow/InstanceMonitor';
|
||||
|
||||
export default function Workflow() {
|
||||
const [activeKey, setActiveKey] = useState('definitions');
|
||||
const { token } = theme.useToken();
|
||||
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Table, Select, Input, Space, Tag, message, theme } from 'antd';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Select, Input, Tag, message, theme } from 'antd';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs';
|
||||
|
||||
@@ -53,12 +53,8 @@ export default function AuditLogViewer() {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const isFirstRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
fetchLogs(query);
|
||||
}
|
||||
fetchLogs(query);
|
||||
}, [query, fetchLogs]);
|
||||
|
||||
const handleFilterChange = (field: keyof AuditLogQuery, value: string | undefined) => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function DictionaryManager() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listDictionaries();
|
||||
setDictionaries(Array.isArray(result) ? result : result.items ?? []);
|
||||
setDictionaries(Array.isArray(result) ? result : result.data ?? []);
|
||||
} catch {
|
||||
message.error('加载字典列表失败');
|
||||
}
|
||||
|
||||
@@ -45,29 +45,6 @@ function flattenMenuTree(tree: MenuItem[]): MenuItem[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Convert flat menu list to tree structure for Table children prop */
|
||||
function buildMenuTree(items: MenuItem[]): MenuItem[] {
|
||||
const map = new Map<string, MenuItem>();
|
||||
const roots: MenuItem[] = [];
|
||||
|
||||
const withChildren = items.map((item) => ({
|
||||
...item,
|
||||
children: [] as MenuItem[],
|
||||
}));
|
||||
|
||||
withChildren.forEach((item) => map.set(item.id, item));
|
||||
|
||||
withChildren.forEach((item) => {
|
||||
if (item.parent_id && map.has(item.parent_id)) {
|
||||
map.get(item.parent_id)!.children!.push(item);
|
||||
} else {
|
||||
roots.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Convert menu tree to TreeSelect data nodes */
|
||||
function toTreeSelectData(
|
||||
items: MenuItem[],
|
||||
@@ -91,7 +68,7 @@ const menuTypeLabels: Record<string, { text: string; color: string }> = {
|
||||
// --- Component ---
|
||||
|
||||
export default function MenuConfig() {
|
||||
const [menus, setMenus] = useState<MenuItem[]>([]);
|
||||
const [_menus, setMenus] = useState<MenuItem[]>([]);
|
||||
const [menuTree, setMenuTree] = useState<MenuItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function NumberingRules() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listNumberingRules();
|
||||
setRules(Array.isArray(result) ? result : result.items ?? []);
|
||||
setRules(Array.isArray(result) ? result : result.data ?? []);
|
||||
} catch {
|
||||
message.error('加载编号规则失败');
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ import { Form, Input, Select, Button, ColorPicker, message, Typography } from 'a
|
||||
import {
|
||||
getTheme,
|
||||
updateTheme,
|
||||
type ThemeConfig,
|
||||
} from '../../api/themes';
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export default function ThemeSettings() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchTheme = useCallback(async () => {
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function InstanceMonitor() {
|
||||
title: '确认挂起',
|
||||
content: '确定要挂起该流程实例吗?挂起后可通过"恢复"按钮继续执行。',
|
||||
okText: '确定挂起',
|
||||
okType: 'warning',
|
||||
okType: 'default',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function ProcessDesigner({ definitionId, onSave }: ProcessDesigne
|
||||
const flowNodes: NodeDef[] = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: (n.data.nodeType as NodeDef['type']) || 'UserTask',
|
||||
name: n.data.name || String(n.data.label),
|
||||
name: String(n.data.name || n.data.label || ''),
|
||||
position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
|
||||
}));
|
||||
const flowEdges: EdgeDef[] = edges.map((e) => ({
|
||||
@@ -220,7 +220,7 @@ export default function ProcessDesigner({ definitionId, onSave }: ProcessDesigne
|
||||
<p style={{ fontWeight: 500, margin: '0 0 8px', fontSize: 12 }}>节点属性</p>
|
||||
<Input
|
||||
size="small"
|
||||
value={selectedNode.data.name || ''}
|
||||
value={String(selectedNode.data.name || '')}
|
||||
onChange={(e) => handleUpdateNodeName(e.target.value)}
|
||||
placeholder="节点名称"
|
||||
style={{ marginBottom: 8 }}
|
||||
|
||||
@@ -3,9 +3,9 @@ import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [react(), ...tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
@@ -22,21 +22,19 @@ export default defineConfig({
|
||||
cssTarget: "chrome120",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
"vendor-react": ["react", "react-dom", "react-router-dom"],
|
||||
"vendor-antd": ["antd", "@ant-design/icons"],
|
||||
"vendor-utils": ["axios", "zustand"],
|
||||
manualChunks(id) {
|
||||
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/") || id.includes("node_modules/react-router-dom")) {
|
||||
return "vendor-react";
|
||||
}
|
||||
if (id.includes("node_modules/antd") || id.includes("node_modules/@ant-design")) {
|
||||
return "vendor-antd";
|
||||
}
|
||||
if (id.includes("node_modules/axios") || id.includes("node_modules/zustand")) {
|
||||
return "vendor-utils";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
minify: "terser",
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
pure_funcs: ["console.log", "console.info", "console.debug"],
|
||||
},
|
||||
},
|
||||
sourcemap: false,
|
||||
reportCompressedSize: false,
|
||||
chunkSizeWarningLimit: 600,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
pub mod department;
|
||||
pub mod organization;
|
||||
pub mod permission;
|
||||
pub mod position;
|
||||
pub mod role;
|
||||
pub mod role_permission;
|
||||
pub mod user;
|
||||
pub mod user_credential;
|
||||
pub mod user_token;
|
||||
pub mod role;
|
||||
pub mod permission;
|
||||
pub mod role_permission;
|
||||
pub mod user_role;
|
||||
pub mod organization;
|
||||
pub mod department;
|
||||
pub mod position;
|
||||
pub mod user_token;
|
||||
|
||||
@@ -66,12 +66,7 @@ where
|
||||
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||
};
|
||||
|
||||
let resp = AuthService::refresh(
|
||||
&req.refresh_token,
|
||||
&state.db,
|
||||
&jwt_config,
|
||||
)
|
||||
.await?;
|
||||
let resp = AuthService::refresh(&req.refresh_token, &state.db, &jwt_config).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ use crate::dto::{
|
||||
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
|
||||
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
|
||||
};
|
||||
use erp_core::rbac::require_permission;
|
||||
use crate::service::dept_service::DeptService;
|
||||
use crate::service::org_service::OrgService;
|
||||
use crate::service::position_service::PositionService;
|
||||
use erp_core::rbac::require_permission;
|
||||
|
||||
// --- Organization handlers ---
|
||||
|
||||
@@ -180,14 +180,7 @@ where
|
||||
{
|
||||
require_permission(&ctx, "department.update")?;
|
||||
|
||||
let dept = DeptService::update(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
let dept = DeptService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(dept)))
|
||||
}
|
||||
|
||||
@@ -284,14 +277,7 @@ where
|
||||
{
|
||||
require_permission(&ctx, "position.update")?;
|
||||
|
||||
let pos = PositionService::update(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
let pos = PositionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(pos)))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
|
||||
use erp_core::rbac::require_permission;
|
||||
use crate::service::permission_service::PermissionService;
|
||||
use crate::service::role_service::RoleService;
|
||||
use erp_core::rbac::require_permission;
|
||||
|
||||
/// GET /api/v1/roles
|
||||
///
|
||||
|
||||
@@ -10,8 +10,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
|
||||
use erp_core::rbac::require_permission;
|
||||
use crate::service::user_service::UserService;
|
||||
use erp_core::rbac::require_permission;
|
||||
|
||||
/// Query parameters for user list endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -41,9 +41,13 @@ where
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let (users, total) =
|
||||
UserService::list(ctx.tenant_id, &pagination, params.search.as_deref(), &state.db)
|
||||
.await?;
|
||||
let (users, total) = UserService::list(
|
||||
ctx.tenant_id,
|
||||
&pagination,
|
||||
params.search.as_deref(),
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
@@ -123,8 +127,7 @@ where
|
||||
{
|
||||
require_permission(&ctx, "user.update")?;
|
||||
|
||||
let user =
|
||||
UserService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
let user = UserService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(user)))
|
||||
}
|
||||
|
||||
@@ -181,8 +184,7 @@ where
|
||||
require_permission(&ctx, "user.update")?;
|
||||
|
||||
let roles =
|
||||
UserService::assign_roles(id, ctx.tenant_id, ctx.user_id, &req.role_ids, &state.db)
|
||||
.await?;
|
||||
UserService::assign_roles(id, ctx.tenant_id, ctx.user_id, &req.role_ids, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(AssignRolesResp { roles })))
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ pub async fn jwt_auth_middleware_fn(
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let claims = TokenService::decode_token(token, &jwt_secret)
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
let claims =
|
||||
TokenService::decode_token(token, &jwt_secret).map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
// Verify this is an access token, not a refresh token
|
||||
if claims.token_type != "access" {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod jwt_auth;
|
||||
|
||||
pub use jwt_auth::jwt_auth_middleware_fn;
|
||||
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
|
||||
pub use jwt_auth::jwt_auth_middleware_fn;
|
||||
|
||||
@@ -101,8 +101,7 @@ impl AuthModule {
|
||||
// Position routes (nested under department)
|
||||
.route(
|
||||
"/departments/{dept_id}/positions",
|
||||
axum::routing::get(org_handler::list_positions)
|
||||
.post(org_handler::create_position),
|
||||
axum::routing::get(org_handler::list_positions).post(org_handler::create_position),
|
||||
)
|
||||
.route(
|
||||
"/positions/{id}",
|
||||
|
||||
@@ -79,10 +79,8 @@ impl AuthService {
|
||||
}
|
||||
|
||||
// 5. Get roles and permissions
|
||||
let roles: Vec<String> =
|
||||
TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
|
||||
let permissions =
|
||||
TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
|
||||
let roles: Vec<String> = TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
|
||||
let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
|
||||
|
||||
// 6. Sign tokens
|
||||
let access_token = TokenService::sign_access_token(
|
||||
@@ -154,10 +152,8 @@ impl AuthService {
|
||||
TokenService::revoke_token(old_token_id, db).await?;
|
||||
|
||||
// Fetch fresh roles and permissions
|
||||
let roles: Vec<String> =
|
||||
TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
|
||||
let permissions =
|
||||
TokenService::get_user_permissions(claims.sub, claims.tid, db).await?;
|
||||
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
|
||||
let permissions = TokenService::get_user_permissions(claims.sub, claims.tid, db).await?;
|
||||
|
||||
// Sign new token pair
|
||||
let access_token = TokenService::sign_access_token(
|
||||
|
||||
@@ -85,7 +85,9 @@ impl DeptService {
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none())
|
||||
.filter(|d| {
|
||||
d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none()
|
||||
})
|
||||
.ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?;
|
||||
|
||||
let parent_path = parent.path.clone().unwrap_or_default();
|
||||
@@ -120,15 +122,25 @@ impl DeptService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"department.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"department.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "department.create", "department")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"department.create",
|
||||
"department",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -205,8 +217,13 @@ impl DeptService {
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "department.update", "department")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"department.update",
|
||||
"department",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -267,15 +284,25 @@ impl DeptService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"department.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"department.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "department.delete", "department")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"department.delete",
|
||||
"department",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -106,15 +106,25 @@ impl OrgService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"organization.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id, "name": req.name }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"organization.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id, "name": req.name }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "organization.create", "organization")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"organization.create",
|
||||
"organization",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -187,8 +197,13 @@ impl OrgService {
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "organization.update", "organization")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"organization.update",
|
||||
"organization",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -246,15 +261,25 @@ impl OrgService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"organization.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"organization.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "organization.delete", "organization")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"organization.delete",
|
||||
"organization",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
|
||||
@@ -105,11 +105,16 @@ impl PositionService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"position.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "position_id": id, "dept_id": dept_id, "name": req.name }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"position.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "position_id": id, "dept_id": dept_id, "name": req.name }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "position.create", "position")
|
||||
@@ -230,11 +235,16 @@ impl PositionService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"position.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "position_id": id }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"position.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "position_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "position.delete", "position")
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{PermissionResp, RoleResp};
|
||||
@@ -127,15 +125,19 @@ impl RoleService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"role.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "role_id": id, "code": code }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"role.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "role_id": id, "code": code }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "role.create", "role")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(tenant_id, Some(operator_id), "role.create", "role").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -190,8 +192,7 @@ impl RoleService {
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "role.update", "role")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(tenant_id, Some(operator_id), "role.update", "role").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -238,15 +239,19 @@ impl RoleService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"role.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "role_id": id }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"role.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "role_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "role.delete", "role")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(tenant_id, Some(operator_id), "role.delete", "role").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -22,52 +22,286 @@ const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
|
||||
("role.read", "查看角色详情", "role", "read", "查看角色信息"),
|
||||
("role.update", "编辑角色", "role", "update", "编辑角色"),
|
||||
("role.delete", "删除角色", "role", "delete", "删除角色"),
|
||||
("permission.list", "查看权限", "permission", "list", "查看权限列表"),
|
||||
("organization.list", "查看组织列表", "organization", "list", "查看组织列表"),
|
||||
("organization.create", "创建组织", "organization", "create", "创建组织"),
|
||||
("organization.update", "编辑组织", "organization", "update", "编辑组织"),
|
||||
("organization.delete", "删除组织", "organization", "delete", "删除组织"),
|
||||
("department.list", "查看部门列表", "department", "list", "查看部门列表"),
|
||||
("department.create", "创建部门", "department", "create", "创建部门"),
|
||||
("department.update", "编辑部门", "department", "update", "编辑部门"),
|
||||
("department.delete", "删除部门", "department", "delete", "删除部门"),
|
||||
("position.list", "查看岗位列表", "position", "list", "查看岗位列表"),
|
||||
("position.create", "创建岗位", "position", "create", "创建岗位"),
|
||||
("position.update", "编辑岗位", "position", "update", "编辑岗位"),
|
||||
("position.delete", "删除岗位", "position", "delete", "删除岗位"),
|
||||
(
|
||||
"permission.list",
|
||||
"查看权限",
|
||||
"permission",
|
||||
"list",
|
||||
"查看权限列表",
|
||||
),
|
||||
(
|
||||
"organization.list",
|
||||
"查看组织列表",
|
||||
"organization",
|
||||
"list",
|
||||
"查看组织列表",
|
||||
),
|
||||
(
|
||||
"organization.create",
|
||||
"创建组织",
|
||||
"organization",
|
||||
"create",
|
||||
"创建组织",
|
||||
),
|
||||
(
|
||||
"organization.update",
|
||||
"编辑组织",
|
||||
"organization",
|
||||
"update",
|
||||
"编辑组织",
|
||||
),
|
||||
(
|
||||
"organization.delete",
|
||||
"删除组织",
|
||||
"organization",
|
||||
"delete",
|
||||
"删除组织",
|
||||
),
|
||||
(
|
||||
"department.list",
|
||||
"查看部门列表",
|
||||
"department",
|
||||
"list",
|
||||
"查看部门列表",
|
||||
),
|
||||
(
|
||||
"department.create",
|
||||
"创建部门",
|
||||
"department",
|
||||
"create",
|
||||
"创建部门",
|
||||
),
|
||||
(
|
||||
"department.update",
|
||||
"编辑部门",
|
||||
"department",
|
||||
"update",
|
||||
"编辑部门",
|
||||
),
|
||||
(
|
||||
"department.delete",
|
||||
"删除部门",
|
||||
"department",
|
||||
"delete",
|
||||
"删除部门",
|
||||
),
|
||||
(
|
||||
"position.list",
|
||||
"查看岗位列表",
|
||||
"position",
|
||||
"list",
|
||||
"查看岗位列表",
|
||||
),
|
||||
(
|
||||
"position.create",
|
||||
"创建岗位",
|
||||
"position",
|
||||
"create",
|
||||
"创建岗位",
|
||||
),
|
||||
(
|
||||
"position.update",
|
||||
"编辑岗位",
|
||||
"position",
|
||||
"update",
|
||||
"编辑岗位",
|
||||
),
|
||||
(
|
||||
"position.delete",
|
||||
"删除岗位",
|
||||
"position",
|
||||
"delete",
|
||||
"删除岗位",
|
||||
),
|
||||
// === Config module ===
|
||||
("dictionary.list", "查看字典", "dictionary", "list", "查看数据字典"),
|
||||
("dictionary.create", "创建字典", "dictionary", "create", "创建数据字典"),
|
||||
("dictionary.update", "编辑字典", "dictionary", "update", "编辑数据字典"),
|
||||
("dictionary.delete", "删除字典", "dictionary", "delete", "删除数据字典"),
|
||||
(
|
||||
"dictionary.list",
|
||||
"查看字典",
|
||||
"dictionary",
|
||||
"list",
|
||||
"查看数据字典",
|
||||
),
|
||||
(
|
||||
"dictionary.create",
|
||||
"创建字典",
|
||||
"dictionary",
|
||||
"create",
|
||||
"创建数据字典",
|
||||
),
|
||||
(
|
||||
"dictionary.update",
|
||||
"编辑字典",
|
||||
"dictionary",
|
||||
"update",
|
||||
"编辑数据字典",
|
||||
),
|
||||
(
|
||||
"dictionary.delete",
|
||||
"删除字典",
|
||||
"dictionary",
|
||||
"delete",
|
||||
"删除数据字典",
|
||||
),
|
||||
("menu.list", "查看菜单", "menu", "list", "查看菜单配置"),
|
||||
("menu.update", "编辑菜单", "menu", "update", "编辑菜单配置"),
|
||||
("setting.read", "查看配置", "setting", "read", "查看系统参数"),
|
||||
("setting.update", "编辑配置", "setting", "update", "编辑系统参数"),
|
||||
("setting.delete", "删除配置", "setting", "delete", "删除系统参数"),
|
||||
("numbering.list", "查看编号规则", "numbering", "list", "查看编号规则"),
|
||||
("numbering.create", "创建编号规则", "numbering", "create", "创建编号规则"),
|
||||
("numbering.update", "编辑编号规则", "numbering", "update", "编辑编号规则"),
|
||||
("numbering.delete", "删除编号规则", "numbering", "delete", "删除编号规则"),
|
||||
("numbering.generate", "生成编号", "numbering", "generate", "生成文档编号"),
|
||||
(
|
||||
"setting.read",
|
||||
"查看配置",
|
||||
"setting",
|
||||
"read",
|
||||
"查看系统参数",
|
||||
),
|
||||
(
|
||||
"setting.update",
|
||||
"编辑配置",
|
||||
"setting",
|
||||
"update",
|
||||
"编辑系统参数",
|
||||
),
|
||||
(
|
||||
"setting.delete",
|
||||
"删除配置",
|
||||
"setting",
|
||||
"delete",
|
||||
"删除系统参数",
|
||||
),
|
||||
(
|
||||
"numbering.list",
|
||||
"查看编号规则",
|
||||
"numbering",
|
||||
"list",
|
||||
"查看编号规则",
|
||||
),
|
||||
(
|
||||
"numbering.create",
|
||||
"创建编号规则",
|
||||
"numbering",
|
||||
"create",
|
||||
"创建编号规则",
|
||||
),
|
||||
(
|
||||
"numbering.update",
|
||||
"编辑编号规则",
|
||||
"numbering",
|
||||
"update",
|
||||
"编辑编号规则",
|
||||
),
|
||||
(
|
||||
"numbering.delete",
|
||||
"删除编号规则",
|
||||
"numbering",
|
||||
"delete",
|
||||
"删除编号规则",
|
||||
),
|
||||
(
|
||||
"numbering.generate",
|
||||
"生成编号",
|
||||
"numbering",
|
||||
"generate",
|
||||
"生成文档编号",
|
||||
),
|
||||
("theme.read", "查看主题", "theme", "read", "查看主题设置"),
|
||||
("theme.update", "编辑主题", "theme", "update", "编辑主题设置"),
|
||||
("language.list", "查看语言", "language", "list", "查看语言配置"),
|
||||
("language.update", "编辑语言", "language", "update", "编辑语言设置"),
|
||||
(
|
||||
"theme.update",
|
||||
"编辑主题",
|
||||
"theme",
|
||||
"update",
|
||||
"编辑主题设置",
|
||||
),
|
||||
(
|
||||
"language.list",
|
||||
"查看语言",
|
||||
"language",
|
||||
"list",
|
||||
"查看语言配置",
|
||||
),
|
||||
(
|
||||
"language.update",
|
||||
"编辑语言",
|
||||
"language",
|
||||
"update",
|
||||
"编辑语言设置",
|
||||
),
|
||||
// === Workflow module ===
|
||||
("workflow.create", "创建流程", "workflow", "create", "创建流程定义"),
|
||||
("workflow.list", "查看流程", "workflow", "list", "查看流程列表"),
|
||||
("workflow.read", "查看流程详情", "workflow", "read", "查看流程定义详情"),
|
||||
("workflow.update", "编辑流程", "workflow", "update", "编辑流程定义"),
|
||||
("workflow.publish", "发布流程", "workflow", "publish", "发布流程定义"),
|
||||
("workflow.start", "发起流程", "workflow", "start", "发起流程实例"),
|
||||
("workflow.approve", "审批任务", "workflow", "approve", "审批流程任务"),
|
||||
("workflow.delegate", "委派任务", "workflow", "delegate", "委派流程任务"),
|
||||
(
|
||||
"workflow.create",
|
||||
"创建流程",
|
||||
"workflow",
|
||||
"create",
|
||||
"创建流程定义",
|
||||
),
|
||||
(
|
||||
"workflow.list",
|
||||
"查看流程",
|
||||
"workflow",
|
||||
"list",
|
||||
"查看流程列表",
|
||||
),
|
||||
(
|
||||
"workflow.read",
|
||||
"查看流程详情",
|
||||
"workflow",
|
||||
"read",
|
||||
"查看流程定义详情",
|
||||
),
|
||||
(
|
||||
"workflow.update",
|
||||
"编辑流程",
|
||||
"workflow",
|
||||
"update",
|
||||
"编辑流程定义",
|
||||
),
|
||||
(
|
||||
"workflow.publish",
|
||||
"发布流程",
|
||||
"workflow",
|
||||
"publish",
|
||||
"发布流程定义",
|
||||
),
|
||||
(
|
||||
"workflow.start",
|
||||
"发起流程",
|
||||
"workflow",
|
||||
"start",
|
||||
"发起流程实例",
|
||||
),
|
||||
(
|
||||
"workflow.approve",
|
||||
"审批任务",
|
||||
"workflow",
|
||||
"approve",
|
||||
"审批流程任务",
|
||||
),
|
||||
(
|
||||
"workflow.delegate",
|
||||
"委派任务",
|
||||
"workflow",
|
||||
"delegate",
|
||||
"委派流程任务",
|
||||
),
|
||||
// === Message module ===
|
||||
("message.list", "查看消息", "message", "list", "查看消息列表"),
|
||||
(
|
||||
"message.list",
|
||||
"查看消息",
|
||||
"message",
|
||||
"list",
|
||||
"查看消息列表",
|
||||
),
|
||||
("message.send", "发送消息", "message", "send", "发送新消息"),
|
||||
("message.template.list", "查看消息模板", "message.template", "list", "查看消息模板列表"),
|
||||
("message.template.create", "创建消息模板", "message.template", "create", "创建消息模板"),
|
||||
(
|
||||
"message.template.list",
|
||||
"查看消息模板",
|
||||
"message.template",
|
||||
"list",
|
||||
"查看消息模板列表",
|
||||
),
|
||||
(
|
||||
"message.template.create",
|
||||
"创建消息模板",
|
||||
"message.template",
|
||||
"create",
|
||||
"创建消息模板",
|
||||
),
|
||||
];
|
||||
|
||||
/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS.
|
||||
@@ -128,7 +362,9 @@ pub async fn seed_tenant_auth(
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
perm.insert(db).await.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
perm.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 2. Create "admin" role with all permissions
|
||||
@@ -147,7 +383,10 @@ pub async fn seed_tenant_auth(
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
admin_role.insert(db).await.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
admin_role
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// Assign all permissions to admin role
|
||||
for perm_id in &perm_ids {
|
||||
@@ -162,7 +401,9 @@ pub async fn seed_tenant_auth(
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
rp.insert(db).await.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
rp.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 3. Create "viewer" role with read-only permissions
|
||||
@@ -181,7 +422,10 @@ pub async fn seed_tenant_auth(
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
viewer_role.insert(db).await.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
viewer_role
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// Assign read permissions to viewer role
|
||||
for idx in READ_PERM_INDICES {
|
||||
@@ -197,7 +441,9 @@ pub async fn seed_tenant_auth(
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
rp.insert(db).await.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
rp.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +468,10 @@ pub async fn seed_tenant_auth(
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
admin_user.insert(db).await.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
admin_user
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// Create password credential for admin user
|
||||
let cred = user_credential::ActiveModel {
|
||||
@@ -239,7 +488,9 @@ pub async fn seed_tenant_auth(
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
cred.insert(db).await.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
cred.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// 5. Assign admin role to admin user
|
||||
let user_role_assignment = user_role::ActiveModel {
|
||||
@@ -253,7 +504,10 @@ pub async fn seed_tenant_auth(
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
user_role_assignment.insert(db).await.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
user_role_assignment
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
tracing::info!(
|
||||
tenant_id = %tenant_id,
|
||||
|
||||
@@ -84,17 +84,21 @@ impl UserService {
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
cred
|
||||
.insert(db)
|
||||
cred.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// Publish domain event
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"user.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "user_id": user_id, "username": req.username }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"user.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "user_id": user_id, "username": req.username }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.create", "user")
|
||||
@@ -147,11 +151,11 @@ impl UserService {
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(term) = search && !term.is_empty() {
|
||||
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)),
|
||||
);
|
||||
query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term)));
|
||||
}
|
||||
|
||||
let paginator = query.paginate(db, pagination.limit());
|
||||
@@ -225,8 +229,7 @@ impl UserService {
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.update", "user")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.update", "user").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -261,15 +264,19 @@ impl UserService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"user.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "user_id": id }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"user.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "user_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.delete", "user")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.delete", "user").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -302,7 +309,9 @@ impl UserService {
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if found.len() != role_ids.len() {
|
||||
return Err(AuthError::Validation("部分角色不存在或不属于当前租户".to_string()));
|
||||
return Err(AuthError::Validation(
|
||||
"部分角色不存在或不属于当前租户".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +337,10 @@ impl UserService {
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
assignment.insert(db).await.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
assignment
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
audit_service::record(
|
||||
|
||||
@@ -2,5 +2,5 @@ pub mod dictionary;
|
||||
pub mod dictionary_item;
|
||||
pub mod menu;
|
||||
pub mod menu_role;
|
||||
pub mod setting;
|
||||
pub mod numbering_rule;
|
||||
pub mod setting;
|
||||
|
||||
@@ -22,9 +22,7 @@ pub enum ConfigError {
|
||||
impl From<sea_orm::TransactionError<ConfigError>> for ConfigError {
|
||||
fn from(err: sea_orm::TransactionError<ConfigError>) -> Self {
|
||||
match err {
|
||||
sea_orm::TransactionError::Connection(err) => {
|
||||
ConfigError::Validation(err.to_string())
|
||||
}
|
||||
sea_orm::TransactionError::Connection(err) => ConfigError::Validation(err.to_string()),
|
||||
sea_orm::TransactionError::Transaction(inner) => inner,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,14 +97,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "dictionary.update")?;
|
||||
|
||||
let dictionary = DictionaryService::update(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
let dictionary =
|
||||
DictionaryService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(dictionary)))
|
||||
}
|
||||
@@ -185,14 +179,8 @@ where
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let item = DictionaryService::add_item(
|
||||
dict_id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
let item =
|
||||
DictionaryService::add_item(dict_id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(item)))
|
||||
}
|
||||
@@ -214,20 +202,12 @@ where
|
||||
require_permission(&ctx, "dictionary.update")?;
|
||||
|
||||
// 验证 item_id 属于 dict_id
|
||||
let item = DictionaryService::update_item(
|
||||
item_id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
let item = DictionaryService::update_item(item_id, ctx.tenant_id, ctx.user_id, &req, &state.db)
|
||||
.await?;
|
||||
|
||||
// 确保 item 属于指定的 dictionary
|
||||
if item.dictionary_id != dict_id {
|
||||
return Err(AppError::Validation(
|
||||
"字典项不属于指定的字典".to_string(),
|
||||
));
|
||||
return Err(AppError::Validation("字典项不属于指定的字典".to_string()));
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse::ok(item)))
|
||||
|
||||
@@ -30,14 +30,9 @@ where
|
||||
page_size: Some(100),
|
||||
};
|
||||
|
||||
let (settings, _total) = SettingService::list_by_scope(
|
||||
"platform",
|
||||
&None,
|
||||
ctx.tenant_id,
|
||||
&pagination,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
let (settings, _total) =
|
||||
SettingService::list_by_scope("platform", &None, ctx.tenant_id, &pagination, &state.db)
|
||||
.await?;
|
||||
|
||||
let languages: Vec<LanguageResp> = settings
|
||||
.into_iter()
|
||||
@@ -83,7 +78,7 @@ where
|
||||
|
||||
SettingService::set(
|
||||
SetSettingParams {
|
||||
key,
|
||||
key: key.clone(),
|
||||
scope: "platform".to_string(),
|
||||
scope_id: None,
|
||||
value,
|
||||
@@ -96,9 +91,20 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 从返回的 SettingResp 中读取实际值
|
||||
let updated = SettingService::get(&key, "platform", &None, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
// 尝试从 value 中提取 name,否则用 code 作为默认名称
|
||||
let name = updated
|
||||
.setting_value
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&code)
|
||||
.to_string();
|
||||
|
||||
Ok(JsonResponse(ApiResponse::ok(LanguageResp {
|
||||
code,
|
||||
name: String::new(),
|
||||
name,
|
||||
is_active: req.is_active,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -142,8 +142,7 @@ where
|
||||
role_ids: item.role_ids.clone(),
|
||||
version,
|
||||
};
|
||||
MenuService::update(id, ctx.tenant_id, ctx.user_id, &update_req, &state.db)
|
||||
.await?;
|
||||
MenuService::update(id, ctx.tenant_id, ctx.user_id, &update_req, &state.db).await?;
|
||||
}
|
||||
None => {
|
||||
let create_req = CreateMenuReq {
|
||||
|
||||
@@ -91,8 +91,7 @@ where
|
||||
{
|
||||
require_permission(&ctx, "numbering.update")?;
|
||||
|
||||
let rule =
|
||||
NumberingService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
let rule = NumberingService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(rule)))
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ where
|
||||
{
|
||||
require_permission(&ctx, "theme.read")?;
|
||||
|
||||
let setting =
|
||||
SettingService::get("theme", "tenant", &None, ctx.tenant_id, &state.db).await?;
|
||||
let setting = SettingService::get("theme", "tenant", &None, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
let theme: ThemeResp = serde_json::from_value(setting.setting_value)
|
||||
.map_err(|e| AppError::Validation(format!("主题配置解析失败: {e}")))?;
|
||||
|
||||
@@ -50,8 +50,7 @@ impl ConfigModule {
|
||||
)
|
||||
.route(
|
||||
"/config/dictionaries/{dict_id}/items/{item_id}",
|
||||
put(dictionary_handler::update_item)
|
||||
.delete(dictionary_handler::delete_item),
|
||||
put(dictionary_handler::update_item).delete(dictionary_handler::delete_item),
|
||||
)
|
||||
// Menu routes
|
||||
.route(
|
||||
@@ -62,8 +61,7 @@ impl ConfigModule {
|
||||
)
|
||||
.route(
|
||||
"/config/menus/{id}",
|
||||
put(menu_handler::update_menu)
|
||||
.delete(menu_handler::delete_menu),
|
||||
put(menu_handler::update_menu).delete(menu_handler::delete_menu),
|
||||
)
|
||||
// Setting routes
|
||||
.route(
|
||||
@@ -93,10 +91,7 @@ impl ConfigModule {
|
||||
get(theme_handler::get_theme).put(theme_handler::update_theme),
|
||||
)
|
||||
// Language routes
|
||||
.route(
|
||||
"/config/languages",
|
||||
get(language_handler::list_languages),
|
||||
)
|
||||
.route("/config/languages", get(language_handler::list_languages))
|
||||
.route(
|
||||
"/config/languages/{code}",
|
||||
put(language_handler::update_language),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{DictionaryItemResp, DictionaryResp};
|
||||
@@ -133,15 +131,25 @@ impl DictionaryService {
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"dictionary.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dictionary_id": id, "code": code }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"dictionary.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dictionary_id": id, "code": code }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "dictionary.create", "dictionary")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"dictionary.create",
|
||||
"dictionary",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -198,8 +206,13 @@ impl DictionaryService {
|
||||
let items = Self::fetch_items(updated.id, tenant_id, db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "dictionary.update", "dictionary")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"dictionary.update",
|
||||
"dictionary",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -244,15 +257,25 @@ impl DictionaryService {
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"dictionary.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dictionary_id": id }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"dictionary.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dictionary_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "dictionary.delete", "dictionary")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"dictionary.delete",
|
||||
"dictionary",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -315,8 +338,13 @@ impl DictionaryService {
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "dictionary_item.create", "dictionary_item")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"dictionary_item.create",
|
||||
"dictionary_item",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -376,8 +404,13 @@ impl DictionaryService {
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "dictionary_item.update", "dictionary_item")
|
||||
.with_resource_id(item_id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"dictionary_item.update",
|
||||
"dictionary_item",
|
||||
)
|
||||
.with_resource_id(item_id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -423,8 +456,13 @@ impl DictionaryService {
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "dictionary_item.delete", "dictionary_item")
|
||||
.with_resource_id(item_id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"dictionary_item.delete",
|
||||
"dictionary_item",
|
||||
)
|
||||
.with_resource_id(item_id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateMenuReq, MenuResp};
|
||||
@@ -58,19 +56,13 @@ impl MenuService {
|
||||
|
||||
// 3. 按 parent_id 分组构建 HashMap
|
||||
let filtered: Vec<&menu::Model> = match &visible_menu_ids {
|
||||
Some(ids) => all_menus
|
||||
.iter()
|
||||
.filter(|m| ids.contains(&m.id))
|
||||
.collect(),
|
||||
Some(ids) => all_menus.iter().filter(|m| ids.contains(&m.id)).collect(),
|
||||
None => all_menus.iter().collect(),
|
||||
};
|
||||
|
||||
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||
for m in &filtered {
|
||||
children_map
|
||||
.entry(m.parent_id)
|
||||
.or_default()
|
||||
.push(*m);
|
||||
children_map.entry(m.parent_id).or_default().push(*m);
|
||||
}
|
||||
|
||||
// 4. 递归构建树形结构(从 parent_id == None 的根节点开始)
|
||||
@@ -152,15 +144,19 @@ impl MenuService {
|
||||
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
|
||||
}
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"menu.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "menu_id": id, "title": req.title }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"menu.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "menu_id": id, "title": req.title }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "menu.create", "menu")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(tenant_id, Some(operator_id), "menu.create", "menu").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -235,8 +231,7 @@ impl MenuService {
|
||||
}
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "menu.update", "menu")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(tenant_id, Some(operator_id), "menu.update", "menu").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -285,15 +280,19 @@ impl MenuService {
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"menu.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "menu_id": id }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"menu.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "menu_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "menu.delete", "menu")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(tenant_id, Some(operator_id), "menu.delete", "menu").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -370,10 +369,7 @@ impl MenuService {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let children = children_map
|
||||
.get(&Some(m.id))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let children = children_map.get(&Some(m.id)).cloned().unwrap_or_default();
|
||||
MenuResp {
|
||||
id: m.id,
|
||||
parent_id: m.parent_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::{Datelike, NaiveDate, Utc};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
Statement, ConnectionTrait, DatabaseBackend, TransactionTrait,
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, PaginatorTrait,
|
||||
QueryFilter, Set, Statement, TransactionTrait,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -41,10 +41,7 @@ impl NumberingService {
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
let resps: Vec<NumberingRuleResp> = models
|
||||
.iter()
|
||||
.map(Self::model_to_resp)
|
||||
.collect();
|
||||
let resps: Vec<NumberingRuleResp> = models.iter().map(Self::model_to_resp).collect();
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
@@ -89,7 +86,10 @@ impl NumberingService {
|
||||
seq_start: Set(seq_start),
|
||||
seq_current: Set(seq_start as i64),
|
||||
separator: Set(req.separator.clone().unwrap_or_else(|| "-".to_string())),
|
||||
reset_cycle: Set(req.reset_cycle.clone().unwrap_or_else(|| "never".to_string())),
|
||||
reset_cycle: Set(req
|
||||
.reset_cycle
|
||||
.clone()
|
||||
.unwrap_or_else(|| "never".to_string())),
|
||||
last_reset_date: Set(Some(Utc::now().date_naive())),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
@@ -103,15 +103,25 @@ impl NumberingService {
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"numbering_rule.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "rule_id": id, "code": req.code }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"numbering_rule.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "rule_id": id, "code": req.code }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "numbering_rule.create", "numbering_rule")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"numbering_rule.create",
|
||||
"numbering_rule",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -126,7 +136,10 @@ impl NumberingService {
|
||||
seq_start,
|
||||
seq_current: seq_start as i64,
|
||||
separator: req.separator.clone().unwrap_or_else(|| "-".to_string()),
|
||||
reset_cycle: req.reset_cycle.clone().unwrap_or_else(|| "never".to_string()),
|
||||
reset_cycle: req
|
||||
.reset_cycle
|
||||
.clone()
|
||||
.unwrap_or_else(|| "never".to_string()),
|
||||
last_reset_date: Some(Utc::now().date_naive().to_string()),
|
||||
version: 1,
|
||||
})
|
||||
@@ -181,8 +194,13 @@ impl NumberingService {
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "numbering_rule.update", "numbering_rule")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"numbering_rule.update",
|
||||
"numbering_rule",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -219,15 +237,25 @@ impl NumberingService {
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"numbering_rule.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "rule_id": id }),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"numbering_rule.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "rule_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "numbering_rule.delete", "numbering_rule")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"numbering_rule.delete",
|
||||
"numbering_rule",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::SettingResp;
|
||||
@@ -46,9 +44,7 @@ impl SettingService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<SettingResp> {
|
||||
// 1. Try exact match
|
||||
if let Some(resp) =
|
||||
Self::find_exact(key, scope, scope_id, tenant_id, db).await?
|
||||
{
|
||||
if let Some(resp) = Self::find_exact(key, scope, scope_id, tenant_id, db).await? {
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
@@ -81,12 +77,18 @@ impl SettingService {
|
||||
event_bus: &EventBus,
|
||||
) -> ConfigResult<SettingResp> {
|
||||
// Look for an existing non-deleted record
|
||||
let existing = setting::Entity::find()
|
||||
let mut query = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(¶ms.scope))
|
||||
.filter(setting::Column::ScopeId.eq(params.scope_id))
|
||||
.filter(setting::Column::SettingKey.eq(¶ms.key))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.filter(setting::Column::DeletedAt.is_null());
|
||||
|
||||
query = match params.scope_id {
|
||||
Some(id) => query.filter(setting::Column::ScopeId.eq(id)),
|
||||
None => query.filter(setting::Column::ScopeId.is_null()),
|
||||
};
|
||||
|
||||
let existing = query
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
@@ -94,7 +96,9 @@ impl SettingService {
|
||||
if let Some(model) = existing {
|
||||
// Update existing record — 乐观锁校验
|
||||
let next_version = match params.version {
|
||||
Some(v) => check_version(v, model.version).map_err(|_| ConfigError::VersionMismatch)?,
|
||||
Some(v) => {
|
||||
check_version(v, model.version).map_err(|_| ConfigError::VersionMismatch)?
|
||||
}
|
||||
None => model.version + 1,
|
||||
};
|
||||
|
||||
@@ -109,15 +113,20 @@ impl SettingService {
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"setting.updated",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"setting_id": updated.id,
|
||||
"key": params.key,
|
||||
"scope": params.scope,
|
||||
}),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"setting.updated",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"setting_id": updated.id,
|
||||
"key": params.key,
|
||||
"scope": params.scope,
|
||||
}),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting")
|
||||
@@ -150,15 +159,20 @@ impl SettingService {
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"setting.created",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"setting_id": id,
|
||||
"key": params.key,
|
||||
"scope": params.scope,
|
||||
}),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"setting.created",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"setting_id": id,
|
||||
"key": params.key,
|
||||
"scope": params.scope,
|
||||
}),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting")
|
||||
@@ -179,12 +193,17 @@ impl SettingService {
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<(Vec<SettingResp>, u64)> {
|
||||
let paginator = setting::Entity::find()
|
||||
let mut query = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.paginate(db, pagination.limit());
|
||||
.filter(setting::Column::DeletedAt.is_null());
|
||||
|
||||
query = match scope_id {
|
||||
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
|
||||
None => query.filter(setting::Column::ScopeId.is_null()),
|
||||
};
|
||||
|
||||
let paginator = query.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
@@ -197,8 +216,7 @@ impl SettingService {
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
let resps: Vec<SettingResp> =
|
||||
models.iter().map(Self::model_to_resp).collect();
|
||||
let resps: Vec<SettingResp> = models.iter().map(Self::model_to_resp).collect();
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
@@ -214,20 +232,23 @@ impl SettingService {
|
||||
version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<()> {
|
||||
let model = setting::Entity::find()
|
||||
let mut query = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::SettingKey.eq(key))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.filter(setting::Column::DeletedAt.is_null());
|
||||
|
||||
query = match scope_id {
|
||||
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
|
||||
None => query.filter(setting::Column::ScopeId.is_null()),
|
||||
};
|
||||
|
||||
let model = query
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
ConfigError::NotFound(format!(
|
||||
"设置 '{}' 在 '{}' 作用域下不存在",
|
||||
key, scope
|
||||
))
|
||||
ConfigError::NotFound(format!("设置 '{}' 在 '{}' 作用域下不存在", key, scope))
|
||||
})?;
|
||||
|
||||
let next_version =
|
||||
@@ -264,12 +285,19 @@ impl SettingService {
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<Option<SettingResp>> {
|
||||
let model = setting::Entity::find()
|
||||
let mut query = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::SettingKey.eq(key))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.filter(setting::Column::DeletedAt.is_null());
|
||||
|
||||
// SQL 中 `= NULL` 永远返回 false,必须用 IS NULL 匹配 NULL 值
|
||||
query = match scope_id {
|
||||
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
|
||||
None => query.filter(setting::Column::ScopeId.is_null()),
|
||||
};
|
||||
|
||||
let model = query
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
@@ -301,9 +329,7 @@ impl SettingService {
|
||||
(SCOPE_TENANT.to_string(), Some(tenant_id)),
|
||||
(SCOPE_PLATFORM.to_string(), None),
|
||||
]),
|
||||
SCOPE_TENANT => {
|
||||
Ok(vec![(SCOPE_PLATFORM.to_string(), None)])
|
||||
}
|
||||
SCOPE_TENANT => Ok(vec![(SCOPE_PLATFORM.to_string(), None)]),
|
||||
SCOPE_PLATFORM => Ok(vec![]),
|
||||
_ => Err(ConfigError::Validation(format!(
|
||||
"不支持的作用域类型: '{}'",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
/// 统一错误响应格式
|
||||
|
||||
@@ -57,7 +57,11 @@ impl ModuleRegistry {
|
||||
}
|
||||
|
||||
pub fn register(mut self, module: impl ErpModule + 'static) -> Self {
|
||||
tracing::info!(module = module.name(), version = module.version(), "Module registered");
|
||||
tracing::info!(
|
||||
module = module.name(),
|
||||
version = module.version(),
|
||||
"Module registered"
|
||||
);
|
||||
let mut modules = (*self.modules).clone();
|
||||
modules.push(Arc::new(module));
|
||||
self.modules = Arc::new(modules);
|
||||
|
||||
@@ -15,10 +15,7 @@ pub fn require_permission(ctx: &TenantContext, permission: &str) -> Result<(), A
|
||||
/// Check whether the `TenantContext` includes at least one of the specified permission codes.
|
||||
///
|
||||
/// Useful when multiple permissions can grant access to the same resource.
|
||||
pub fn require_any_permission(
|
||||
ctx: &TenantContext,
|
||||
permissions: &[&str],
|
||||
) -> Result<(), AppError> {
|
||||
pub fn require_any_permission(ctx: &TenantContext, permissions: &[&str]) -> Result<(), AppError> {
|
||||
let has_any = permissions
|
||||
.iter()
|
||||
.any(|p| ctx.permissions.iter().any(|up| up == *p));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
// ============ 消息 DTO ============
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::extract::FromRef;
|
||||
use axum::Json;
|
||||
use axum::extract::FromRef;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::extract::FromRef;
|
||||
use axum::Json;
|
||||
use axum::extract::FromRef;
|
||||
use axum::extract::{Extension, State};
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
@@ -19,13 +19,7 @@ where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let resp = SubscriptionService::upsert(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&_state.db,
|
||||
)
|
||||
.await?;
|
||||
let resp = SubscriptionService::upsert(ctx.tenant_id, ctx.user_id, &req, &_state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::extract::FromRef;
|
||||
use axum::Json;
|
||||
use axum::extract::FromRef;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use serde::Deserialize;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
|
||||
@@ -8,9 +8,7 @@ use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::ErpModule;
|
||||
|
||||
use crate::handler::{
|
||||
message_handler, subscription_handler, template_handler,
|
||||
};
|
||||
use crate::handler::{message_handler, subscription_handler, template_handler};
|
||||
|
||||
/// 消息中心模块,实现 ErpModule trait。
|
||||
pub struct MessageModule;
|
||||
@@ -32,22 +30,10 @@ impl MessageModule {
|
||||
"/messages",
|
||||
get(message_handler::list_messages).post(message_handler::send_message),
|
||||
)
|
||||
.route(
|
||||
"/messages/unread-count",
|
||||
get(message_handler::unread_count),
|
||||
)
|
||||
.route(
|
||||
"/messages/{id}/read",
|
||||
put(message_handler::mark_read),
|
||||
)
|
||||
.route(
|
||||
"/messages/read-all",
|
||||
put(message_handler::mark_all_read),
|
||||
)
|
||||
.route(
|
||||
"/messages/{id}",
|
||||
delete(message_handler::delete_message),
|
||||
)
|
||||
.route("/messages/unread-count", get(message_handler::unread_count))
|
||||
.route("/messages/{id}/read", put(message_handler::mark_read))
|
||||
.route("/messages/read-all", put(message_handler::mark_all_read))
|
||||
.route("/messages/{id}", delete(message_handler::delete_message))
|
||||
// 模板路由
|
||||
.route(
|
||||
"/message-templates",
|
||||
@@ -79,9 +65,7 @@ impl MessageModule {
|
||||
// 先获取许可,再 spawn 任务
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit.acquire().await.unwrap();
|
||||
if let Err(e) =
|
||||
handle_workflow_event(&event, &db, &event_bus).await
|
||||
{
|
||||
if let Err(e) = handle_workflow_event(&event, &db, &event_bus).await {
|
||||
tracing::warn!(
|
||||
event_type = %event.event_type,
|
||||
error = %e,
|
||||
@@ -146,11 +130,12 @@ async fn handle_workflow_event(
|
||||
) -> Result<(), String> {
|
||||
match event.event_type.as_str() {
|
||||
"process_instance.started" => {
|
||||
let instance_id = event.payload.get("instance_id")
|
||||
let instance_id = event
|
||||
.payload
|
||||
.get("instance_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let starter_id = event.payload.get("started_by")
|
||||
.and_then(|v| v.as_str());
|
||||
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) {
|
||||
@@ -174,11 +159,12 @@ async fn handle_workflow_event(
|
||||
}
|
||||
"task.completed" => {
|
||||
// 任务完成时通知流程发起人
|
||||
let task_id = event.payload.get("task_id")
|
||||
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());
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
Statement, ConnectionTrait, DatabaseBackend,
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, PaginatorTrait,
|
||||
QueryFilter, Set, Statement,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -122,15 +122,20 @@ impl MessageService {
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"message.sent",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"message_id": id,
|
||||
"recipient_id": req.recipient_id,
|
||||
"title": req.title,
|
||||
}),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"message.sent",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"message_id": id,
|
||||
"recipient_id": req.recipient_id,
|
||||
"title": req.title,
|
||||
}),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(sender_id), "message.send", "message")
|
||||
@@ -191,18 +196,28 @@ impl MessageService {
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"message.sent",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"message_id": id,
|
||||
"recipient_id": recipient_id,
|
||||
}),
|
||||
), db).await;
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"message.sent",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"message_id": id,
|
||||
"recipient_id": recipient_id,
|
||||
}),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(system_user), "message.send_system", "message")
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(system_user),
|
||||
"message.send_system",
|
||||
"message",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -301,9 +316,7 @@ impl MessageService {
|
||||
.ok_or_else(|| MessageError::NotFound(format!("消息不存在: {id}")))?;
|
||||
|
||||
if model.recipient_id != user_id {
|
||||
return Err(MessageError::Validation(
|
||||
"只能删除自己的消息".to_string(),
|
||||
));
|
||||
return Err(MessageError::Validation("只能删除自己的消息".to_string()));
|
||||
}
|
||||
|
||||
let current_version = model.version;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateTemplateReq, MessageTemplateResp};
|
||||
|
||||
18
crates/erp-plugin-prototype/Cargo.toml
Normal file
18
crates/erp-plugin-prototype/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "erp-plugin-prototype"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "WASM 插件系统原型验证 — Host 端运行时"
|
||||
|
||||
[dependencies]
|
||||
wasmtime = "43"
|
||||
wasmtime-wasi = "43"
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[[test]]
|
||||
name = "test_plugin_integration"
|
||||
path = "tests/test_plugin_integration.rs"
|
||||
208
crates/erp-plugin-prototype/src/lib.rs
Normal file
208
crates/erp-plugin-prototype/src/lib.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! WASM 插件原型验证 — Host 端运行时
|
||||
//!
|
||||
//! 验证目标:
|
||||
//! - V1: WIT 接口定义 + bindgen! 宏编译通过
|
||||
//! - V2: Host 调用插件导出函数(init / handle_event)
|
||||
//! - V3: 插件调用 Host 导入函数(db_insert / log_write)
|
||||
//! - V4: async 支持(Host async 函数正确桥接)
|
||||
//! - V5: Fuel + Epoch 资源限制
|
||||
//! - V6: 从二进制动态加载
|
||||
|
||||
use anyhow::Result;
|
||||
use wasmtime::component::{Component, HasSelf, Linker, bindgen};
|
||||
use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder};
|
||||
|
||||
/// Host 端状态,绑定到每个 Store 实例
|
||||
pub struct HostState {
|
||||
/// Store 级资源限制
|
||||
pub(crate) limits: StoreLimits,
|
||||
/// 模拟数据库操作记录
|
||||
pub db_ops: Vec<DbOperation>,
|
||||
/// 日志记录
|
||||
pub logs: Vec<(String, String)>,
|
||||
/// 发布的事件
|
||||
pub events: Vec<(String, Vec<u8>)>,
|
||||
/// 配置存储(模拟)
|
||||
pub config_map: std::collections::HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 数据库操作记录
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DbOperation {
|
||||
pub op_type: String,
|
||||
pub entity: String,
|
||||
pub data: Option<Vec<u8>>,
|
||||
pub id: Option<String>,
|
||||
pub version: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for HostState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl HostState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
limits: StoreLimitsBuilder::new().build(),
|
||||
db_ops: Vec::new(),
|
||||
logs: Vec::new(),
|
||||
events: Vec::new(),
|
||||
config_map: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bindgen! 生成类型化绑定(包含 Host trait 和 add_to_linker)
|
||||
bindgen!({
|
||||
path: "./wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
// 实现 bindgen 生成的 Host trait — 插件调用 Host API 的入口
|
||||
impl erp::plugin::host_api::Host for HostState {
|
||||
fn db_insert(&mut self, entity: String, data: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
let id = format!("id-{}", self.db_ops.len() + 1);
|
||||
let record = serde_json::json!({
|
||||
"id": id,
|
||||
"tenant_id": "tenant-default",
|
||||
"entity": entity,
|
||||
"data": serde_json::from_slice::<serde_json::Value>(&data).unwrap_or(serde_json::Value::Null),
|
||||
});
|
||||
let result = serde_json::to_vec(&record).map_err(|e| e.to_string())?;
|
||||
|
||||
self.db_ops.push(DbOperation {
|
||||
op_type: "insert".into(),
|
||||
entity: entity.clone(),
|
||||
data: Some(data),
|
||||
id: Some(id),
|
||||
version: None,
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn db_query(
|
||||
&mut self,
|
||||
entity: String,
|
||||
_filter: Vec<u8>,
|
||||
_pagination: Vec<u8>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let results: Vec<serde_json::Value> = self
|
||||
.db_ops
|
||||
.iter()
|
||||
.filter(|op| op.entity == entity && op.op_type == "insert")
|
||||
.map(|op| {
|
||||
serde_json::json!({
|
||||
"id": op.id,
|
||||
"entity": op.entity,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_vec(&results).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn db_update(
|
||||
&mut self,
|
||||
entity: String,
|
||||
id: String,
|
||||
data: Vec<u8>,
|
||||
version: i64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let record = serde_json::json!({
|
||||
"id": id,
|
||||
"tenant_id": "tenant-default",
|
||||
"entity": entity,
|
||||
"version": version + 1,
|
||||
"data": serde_json::from_slice::<serde_json::Value>(&data).unwrap_or(serde_json::Value::Null),
|
||||
});
|
||||
let result = serde_json::to_vec(&record).map_err(|e| e.to_string())?;
|
||||
|
||||
self.db_ops.push(DbOperation {
|
||||
op_type: "update".into(),
|
||||
entity,
|
||||
data: Some(data),
|
||||
id: Some(id),
|
||||
version: Some(version),
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn db_delete(&mut self, entity: String, id: String) -> Result<(), String> {
|
||||
self.db_ops.push(DbOperation {
|
||||
op_type: "delete".into(),
|
||||
entity,
|
||||
data: None,
|
||||
id: Some(id),
|
||||
version: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn event_publish(&mut self, event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
self.events.push((event_type, payload));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_get(&mut self, key: String) -> Result<Vec<u8>, String> {
|
||||
self.config_map
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("配置项 '{}' 不存在", key))
|
||||
}
|
||||
|
||||
fn log_write(&mut self, level: String, message: String) {
|
||||
self.logs.push((level, message));
|
||||
}
|
||||
|
||||
fn current_user(&mut self) -> Result<Vec<u8>, String> {
|
||||
let user = serde_json::json!({
|
||||
"id": "user-default",
|
||||
"username": "admin",
|
||||
"tenant_id": "tenant-default",
|
||||
});
|
||||
serde_json::to_vec(&user).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn check_permission(&mut self, _permission: String) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Wasmtime Engine(启用 Fuel 限制)
|
||||
pub fn create_engine() -> Result<Engine> {
|
||||
let mut config = Config::new();
|
||||
config.wasm_component_model(true);
|
||||
config.consume_fuel(true);
|
||||
Ok(Engine::new(&config)?)
|
||||
}
|
||||
|
||||
/// 创建带 Fuel 限制的 Store
|
||||
pub fn create_store(engine: &Engine, fuel: u64) -> Result<Store<HostState>> {
|
||||
let state = HostState::new();
|
||||
let mut store = Store::new(engine, state);
|
||||
store.set_fuel(fuel)?;
|
||||
store.limiter(|state| &mut state.limits);
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// 从 WASM 二进制加载并实例化插件
|
||||
pub async fn load_plugin(
|
||||
engine: &Engine,
|
||||
wasm_bytes: &[u8],
|
||||
fuel: u64,
|
||||
) -> Result<(Store<HostState>, PluginWorld)> {
|
||||
let mut store = create_store(engine, fuel)?;
|
||||
let component = Component::from_binary(engine, wasm_bytes)?;
|
||||
|
||||
let mut linker = Linker::new(engine);
|
||||
// 注册 Host API 到 Linker,使插件可以调用 host 函数
|
||||
// HasSelf<HostState> 表示 Data<'a> = &'a mut HostState
|
||||
PluginWorld::add_to_linker::<_, HasSelf<HostState>>(&mut linker, |state| state)?;
|
||||
|
||||
let instance = PluginWorld::instantiate_async(&mut store, &component, &linker).await?;
|
||||
|
||||
Ok((store, instance))
|
||||
}
|
||||
10
crates/erp-plugin-prototype/src/main.rs
Normal file
10
crates/erp-plugin-prototype/src/main.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use erp_plugin_prototype::*;
|
||||
|
||||
fn _discover_api(instance: PluginWorld, mut store: wasmtime::Store<HostState>) {
|
||||
let api = instance.erp_plugin_plugin_api();
|
||||
let _ = api.call_init(&mut store);
|
||||
let _ = api.call_on_tenant_created(&mut store, "test-tenant");
|
||||
let _ = api.call_handle_event(&mut store, "test.event", &[1u8]);
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
220
crates/erp-plugin-prototype/tests/test_plugin_integration.rs
Normal file
220
crates/erp-plugin-prototype/tests/test_plugin_integration.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
//! WASM 插件集成测试
|
||||
//!
|
||||
//! 验证目标:
|
||||
//! V1 — WIT 接口 + bindgen! 编译通过
|
||||
//! V2 — Host 调用插件 init() / handle_event()
|
||||
//! V3 — 插件回调 Host db_insert / log_write
|
||||
//! V4 — async 实例化桥接
|
||||
//! V5 — Fuel 资源限制
|
||||
//! V6 — 从二进制动态加载
|
||||
|
||||
use anyhow::Result;
|
||||
use erp_plugin_prototype::{create_engine, load_plugin};
|
||||
|
||||
/// 获取测试插件 WASM Component 文件路径
|
||||
fn wasm_path() -> String {
|
||||
let candidates = [
|
||||
// 预构建的 WASM Component(通过 wasm-tools component new 生成)
|
||||
"../../target/erp_plugin_test_sample.component.wasm".into(),
|
||||
// 备选:绝对路径
|
||||
format!(
|
||||
"{}/../../target/erp_plugin_test_sample.component.wasm",
|
||||
std::env::current_dir().unwrap().display()
|
||||
),
|
||||
];
|
||||
for path in &candidates {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return path.clone();
|
||||
}
|
||||
}
|
||||
candidates[0].clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v6_load_plugin_from_binary() -> Result<()> {
|
||||
let wasm_path = wasm_path();
|
||||
let wasm_bytes = std::fs::read(&wasm_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"读取 WASM 失败: {}。请先编译: cargo build -p erp-plugin-test-sample --target wasm32-unknown-unknown --release\n路径: {}",
|
||||
e, wasm_path
|
||||
)
|
||||
})?;
|
||||
|
||||
assert!(!wasm_bytes.is_empty(), "WASM 文件不应为空");
|
||||
println!("WASM 文件大小: {} bytes", wasm_bytes.len());
|
||||
|
||||
let engine = create_engine()?;
|
||||
let (_store, _instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v2_host_calls_plugin_init() -> Result<()> {
|
||||
let wasm_bytes = std::fs::read(wasm_path())?;
|
||||
let engine = create_engine()?;
|
||||
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
|
||||
|
||||
// V2: 调用插件 init()
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_init(&mut store)?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 验证 Host 端收到了日志
|
||||
let state = store.data();
|
||||
assert!(
|
||||
state
|
||||
.logs
|
||||
.iter()
|
||||
.any(|(_, m)| m.contains("测试插件初始化成功")),
|
||||
"应收到初始化日志"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v3_plugin_calls_host_api() -> Result<()> {
|
||||
let wasm_bytes = std::fs::read(wasm_path())?;
|
||||
let engine = create_engine()?;
|
||||
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
|
||||
|
||||
// 先 init(插件会调用 db_insert 和 log_write)
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_init(&mut store)?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let state = store.data();
|
||||
|
||||
// 验证 db_insert 被调用
|
||||
assert!(
|
||||
state
|
||||
.db_ops
|
||||
.iter()
|
||||
.any(|op| op.op_type == "insert" && op.entity == "inventory_item"),
|
||||
"应有 inventory_item 的 insert 操作"
|
||||
);
|
||||
|
||||
// 验证 log_write 被调用
|
||||
assert!(
|
||||
state.logs.iter().any(|(_, m)| m.contains("插入成功")),
|
||||
"应有插入成功的日志"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v3_plugin_handle_event_with_db_callback() -> Result<()> {
|
||||
let wasm_bytes = std::fs::read(wasm_path())?;
|
||||
let engine = create_engine()?;
|
||||
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
|
||||
|
||||
// 先 init
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_init(&mut store)?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 发送事件
|
||||
let payload = serde_json::json!({"order_id": "PO-001", "action": "approve"}).to_string();
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_handle_event(&mut store, "workflow.task.completed", payload.as_bytes())?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let state = store.data();
|
||||
|
||||
// 验证 db_update 被调用
|
||||
assert!(
|
||||
state
|
||||
.db_ops
|
||||
.iter()
|
||||
.any(|op| op.op_type == "update" && op.entity == "purchase_order"),
|
||||
"应有 purchase_order 的 update 操作"
|
||||
);
|
||||
|
||||
// 验证事件被发布
|
||||
assert!(
|
||||
state
|
||||
.events
|
||||
.iter()
|
||||
.any(|(t, _)| t == "purchase_order.approved"),
|
||||
"应发布 purchase_order.approved 事件"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_v5_fuel_limit_traps() -> Result<()> {
|
||||
let wasm_bytes = std::fs::read(wasm_path())?;
|
||||
let engine = create_engine()?;
|
||||
|
||||
// 给极少量的 fuel,插件 init() 应该无法完成
|
||||
let result = load_plugin(&engine, &wasm_bytes, 10).await;
|
||||
match result {
|
||||
Ok((mut store, instance)) => {
|
||||
let init_result = instance.erp_plugin_plugin_api().call_init(&mut store);
|
||||
assert!(
|
||||
init_result.is_err() || init_result.unwrap().is_err(),
|
||||
"低 fuel 应导致失败"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
// 实例化就失败了,也是预期行为
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_lifecycle() -> Result<()> {
|
||||
let wasm_bytes = std::fs::read(wasm_path())?;
|
||||
let engine = create_engine()?;
|
||||
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
|
||||
|
||||
// 1. init
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_init(&mut store)?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 2. on_tenant_created
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_on_tenant_created(&mut store, "tenant-new-001")?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 3. handle_event
|
||||
let payload = serde_json::json!({"order_id": "PO-002"}).to_string();
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_handle_event(&mut store, "workflow.task.completed", payload.as_bytes())?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let state = store.data();
|
||||
|
||||
// 完整生命周期验证
|
||||
assert!(
|
||||
state.logs.len() >= 3,
|
||||
"应有多条日志记录,实际: {}",
|
||||
state.logs.len()
|
||||
);
|
||||
assert!(
|
||||
state.db_ops.len() >= 3,
|
||||
"应有多次数据库操作,实际: {}",
|
||||
state.db_ops.len()
|
||||
);
|
||||
assert!(state.events.len() >= 1, "应有事件发布");
|
||||
|
||||
println!("\n=== 完整生命周期验证 ===");
|
||||
println!("日志: {} 条", state.logs.len());
|
||||
println!("DB 操作: {} 次", state.db_ops.len());
|
||||
println!("事件: {} 条", state.events.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
48
crates/erp-plugin-prototype/wit/plugin.wit
Normal file
48
crates/erp-plugin-prototype/wit/plugin.wit
Normal file
@@ -0,0 +1,48 @@
|
||||
package erp:plugin;
|
||||
|
||||
/// 宿主暴露给插件的 API(插件 import 这些函数)
|
||||
interface host-api {
|
||||
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
|
||||
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
|
||||
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 更新记录(自动检查 version 乐观锁)
|
||||
db-update: func(entity: string, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
|
||||
|
||||
/// 软删除记录
|
||||
db-delete: func(entity: string, id: string) -> result<_, string>;
|
||||
|
||||
/// 发布领域事件
|
||||
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 读取系统配置
|
||||
config-get: func(key: string) -> result<list<u8>, string>;
|
||||
|
||||
/// 写日志(自动关联 tenant_id + plugin_id)
|
||||
log-write: func(level: string, message: string);
|
||||
|
||||
/// 获取当前用户信息
|
||||
current-user: func() -> result<list<u8>, string>;
|
||||
|
||||
/// 检查当前用户权限
|
||||
check-permission: func(permission: string) -> result<bool, string>;
|
||||
}
|
||||
|
||||
/// 插件导出的 API(宿主调用这些函数)
|
||||
interface plugin-api {
|
||||
/// 插件初始化(加载时调用一次)
|
||||
init: func() -> result<_, string>;
|
||||
|
||||
/// 租户创建时调用
|
||||
on-tenant-created: func(tenant-id: string) -> result<_, string>;
|
||||
|
||||
/// 处理订阅的事件
|
||||
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
}
|
||||
|
||||
world plugin-world {
|
||||
import host-api;
|
||||
export plugin-api;
|
||||
}
|
||||
13
crates/erp-plugin-test-sample/Cargo.toml
Normal file
13
crates/erp-plugin-test-sample/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "erp-plugin-test-sample"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "WASM 插件系统原型验证 — 测试插件"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
68
crates/erp-plugin-test-sample/src/lib.rs
Normal file
68
crates/erp-plugin-test-sample/src/lib.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! WASM 测试插件 — 验证插件端 API
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::erp::plugin::host_api;
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
struct TestPlugin;
|
||||
|
||||
impl Guest for TestPlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
host_api::log_write("info", "测试插件初始化成功");
|
||||
|
||||
let data = json!({"sku": "TEST-001", "name": "测试商品", "quantity": 100}).to_string();
|
||||
let result = host_api::db_insert("inventory_item", data.as_bytes())
|
||||
.map_err(|e| format!("db_insert 失败: {}", e))?;
|
||||
|
||||
let record: serde_json::Value =
|
||||
serde_json::from_slice(&result).map_err(|e| format!("解析结果失败: {}", e))?;
|
||||
|
||||
host_api::log_write(
|
||||
"info",
|
||||
&format!(
|
||||
"插入成功: id={}, tenant_id={}",
|
||||
record["id"].as_str().unwrap_or("?"),
|
||||
record["tenant_id"].as_str().unwrap_or("?")
|
||||
),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
host_api::log_write("info", &format!("租户创建: {}", tenant_id));
|
||||
let data = json!({"name": "默认分类", "tenant_id": tenant_id}).to_string();
|
||||
host_api::db_insert("inventory_category", data.as_bytes())
|
||||
.map_err(|e| format!("创建默认分类失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
host_api::log_write("info", &format!("处理事件: {}", event_type));
|
||||
let data: serde_json::Value =
|
||||
serde_json::from_slice(&payload).map_err(|e| format!("解析失败: {}", e))?;
|
||||
|
||||
if event_type == "workflow.task.completed" {
|
||||
let order_id = data["order_id"].as_str().unwrap_or("unknown");
|
||||
let update = json!({"status": "approved"}).to_string();
|
||||
host_api::db_update("purchase_order", order_id, update.as_bytes(), 1)
|
||||
.map_err(|e| format!("更新失败: {}", e))?;
|
||||
|
||||
let evt = json!({"order_id": order_id, "status": "approved"}).to_string();
|
||||
host_api::event_publish("purchase_order.approved", evt.as_bytes())
|
||||
.map_err(|e| format!("发布事件失败: {}", e))?;
|
||||
|
||||
host_api::log_write("info", &format!("采购单 {} 审批完成", order_id));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(TestPlugin);
|
||||
@@ -29,6 +29,7 @@ mod m20260413_000026_create_audit_logs;
|
||||
mod m20260414_000027_fix_unique_indexes_soft_delete;
|
||||
mod m20260414_000028_add_standard_fields_to_tokens;
|
||||
mod m20260414_000029_add_standard_fields_to_process_variables;
|
||||
mod m20260414_000032_fix_settings_unique_index_null;
|
||||
mod m20260415_000030_add_version_to_message_tables;
|
||||
mod m20260416_000031_create_domain_events;
|
||||
|
||||
@@ -69,6 +70,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260414_000029_add_standard_fields_to_process_variables::Migration),
|
||||
Box::new(m20260415_000030_add_version_to_message_tables::Migration),
|
||||
Box::new(m20260416_000031_create_domain_events::Migration),
|
||||
Box::new(m20260414_000032_fix_settings_unique_index_null::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,7 @@ impl MigrationTrait for Migration {
|
||||
Table::create()
|
||||
.table(Tenant::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Tenant::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Tenant::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Tenant::Name).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Tenant::Code)
|
||||
|
||||
@@ -11,12 +11,7 @@ impl MigrationTrait for Migration {
|
||||
Table::create()
|
||||
.table(Users::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Users::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Users::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Users::Username).string().not_null())
|
||||
.col(ColumnDef::new(Users::Email).string().null())
|
||||
|
||||
@@ -25,7 +25,11 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.default("password"),
|
||||
)
|
||||
.col(ColumnDef::new(UserCredentials::CredentialData).json().null())
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::CredentialData)
|
||||
.json()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::Verified)
|
||||
.boolean()
|
||||
|
||||
@@ -11,12 +11,7 @@ impl MigrationTrait for Migration {
|
||||
Table::create()
|
||||
.table(Roles::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Roles::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Roles::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Roles::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Roles::Name).string().not_null())
|
||||
.col(ColumnDef::new(Roles::Code).string().not_null())
|
||||
|
||||
@@ -12,7 +12,11 @@ impl MigrationTrait for Migration {
|
||||
.table(RolePermissions::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(RolePermissions::RoleId).uuid().not_null())
|
||||
.col(ColumnDef::new(RolePermissions::PermissionId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(RolePermissions::PermissionId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(RolePermissions::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(RolePermissions::CreatedAt)
|
||||
|
||||
@@ -18,7 +18,11 @@ impl MigrationTrait for Migration {
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(DictionaryItems::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(DictionaryItems::DictionaryId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(DictionaryItems::DictionaryId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(DictionaryItems::Label).string().not_null())
|
||||
.col(ColumnDef::new(DictionaryItems::Value).string().not_null())
|
||||
.col(
|
||||
|
||||
@@ -11,12 +11,7 @@ impl MigrationTrait for Migration {
|
||||
Table::create()
|
||||
.table(Menus::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Menus::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Menus::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Menus::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Menus::ParentId).uuid().null())
|
||||
.col(ColumnDef::new(Menus::Title).string().not_null())
|
||||
|
||||
@@ -11,12 +11,7 @@ impl MigrationTrait for Migration {
|
||||
Table::create()
|
||||
.table(Settings::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Settings::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Settings::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Settings::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Settings::Scope).string().not_null())
|
||||
.col(ColumnDef::new(Settings::ScopeId).uuid().null())
|
||||
|
||||
@@ -17,7 +17,11 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessDefinitions::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::TenantId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessDefinitions::Name).string().not_null())
|
||||
.col(ColumnDef::new(ProcessDefinitions::Key).string().not_null())
|
||||
.col(
|
||||
@@ -27,7 +31,11 @@ impl MigrationTrait for Migration {
|
||||
.default(1),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessDefinitions::Category).string().null())
|
||||
.col(ColumnDef::new(ProcessDefinitions::Description).text().null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::Description)
|
||||
.text()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::Nodes)
|
||||
.json_binary()
|
||||
@@ -58,8 +66,16 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessDefinitions::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(ProcessDefinitions::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::CreatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
|
||||
@@ -18,15 +18,27 @@ impl MigrationTrait for Migration {
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessInstances::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(ProcessInstances::DefinitionId).uuid().not_null())
|
||||
.col(ColumnDef::new(ProcessInstances::BusinessKey).string().null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::DefinitionId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::BusinessKey)
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("running"),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessInstances::StartedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::StartedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::StartedAt)
|
||||
.timestamp_with_time_zone()
|
||||
@@ -50,8 +62,16 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessInstances::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(ProcessInstances::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::CreatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
|
||||
@@ -11,12 +11,7 @@ impl MigrationTrait for Migration {
|
||||
Table::create()
|
||||
.table(Tokens::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Tokens::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Tokens::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Tokens::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Tokens::InstanceId).uuid().not_null())
|
||||
.col(ColumnDef::new(Tokens::NodeId).string().not_null())
|
||||
|
||||
@@ -11,12 +11,7 @@ impl MigrationTrait for Migration {
|
||||
Table::create()
|
||||
.table(Tasks::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Tasks::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Tasks::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Tasks::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Tasks::InstanceId).uuid().not_null())
|
||||
.col(ColumnDef::new(Tasks::TokenId).uuid().not_null())
|
||||
|
||||
@@ -18,7 +18,11 @@ impl MigrationTrait for Migration {
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessVariables::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(ProcessVariables::InstanceId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::InstanceId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessVariables::Name).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::VarType)
|
||||
@@ -27,8 +31,16 @@ impl MigrationTrait for Migration {
|
||||
.default("string"),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessVariables::ValueString).text().null())
|
||||
.col(ColumnDef::new(ProcessVariables::ValueNumber).double().null())
|
||||
.col(ColumnDef::new(ProcessVariables::ValueBoolean).boolean().null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::ValueNumber)
|
||||
.double()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::ValueBoolean)
|
||||
.boolean()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::ValueDate)
|
||||
.timestamp_with_time_zone()
|
||||
|
||||
@@ -18,16 +18,8 @@ impl MigrationTrait for Migration {
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(MessageTemplates::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::Name)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::Code)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(MessageTemplates::Name).string().not_null())
|
||||
.col(ColumnDef::new(MessageTemplates::Code).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::Channel)
|
||||
.string()
|
||||
@@ -50,11 +42,31 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.default("zh-CN"),
|
||||
)
|
||||
.col(ColumnDef::new(MessageTemplates::CreatedAt).timestamp_with_time_zone().not_null())
|
||||
.col(ColumnDef::new(MessageTemplates::UpdatedAt).timestamp_with_time_zone().not_null())
|
||||
.col(ColumnDef::new(MessageTemplates::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(MessageTemplates::UpdatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(MessageTemplates::DeletedAt).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::CreatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -11,12 +11,7 @@ impl MigrationTrait for Migration {
|
||||
Table::create()
|
||||
.table(Messages::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Messages::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Messages::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Messages::TemplateId).uuid().null())
|
||||
.col(ColumnDef::new(Messages::SenderId).uuid().null())
|
||||
@@ -49,26 +44,50 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::ReadAt).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::ReadAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::IsArchived)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::ArchivedAt).timestamp_with_time_zone().null())
|
||||
.col(ColumnDef::new(Messages::SentAt).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::ArchivedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::SentAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("sent"),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::CreatedAt).timestamp_with_time_zone().not_null())
|
||||
.col(ColumnDef::new(Messages::UpdatedAt).timestamp_with_time_zone().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Messages::UpdatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Messages::DeletedAt).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -17,23 +17,63 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(MessageSubscriptions::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(MessageSubscriptions::UserId).uuid().not_null())
|
||||
.col(ColumnDef::new(MessageSubscriptions::NotificationTypes).json().null())
|
||||
.col(ColumnDef::new(MessageSubscriptions::ChannelPreferences).json().null())
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::TenantId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::UserId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::NotificationTypes)
|
||||
.json()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::ChannelPreferences)
|
||||
.json()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::DndEnabled)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(ColumnDef::new(MessageSubscriptions::DndStart).string().null())
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::DndStart)
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.col(ColumnDef::new(MessageSubscriptions::DndEnd).string().null())
|
||||
.col(ColumnDef::new(MessageSubscriptions::CreatedAt).timestamp_with_time_zone().not_null())
|
||||
.col(ColumnDef::new(MessageSubscriptions::UpdatedAt).timestamp_with_time_zone().not_null())
|
||||
.col(ColumnDef::new(MessageSubscriptions::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(MessageSubscriptions::UpdatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(MessageSubscriptions::DeletedAt).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::CreatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -26,20 +26,33 @@ impl MigrationTrait for Migration {
|
||||
.col(ColumnDef::new(AuditLogs::NewValue).json().null())
|
||||
.col(ColumnDef::new(AuditLogs::IpAddress).string().null())
|
||||
.col(ColumnDef::new(AuditLogs::UserAgent).text().null())
|
||||
.col(ColumnDef::new(AuditLogs::CreatedAt).timestamp_with_time_zone().not_null())
|
||||
.col(
|
||||
ColumnDef::new(AuditLogs::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE INDEX idx_audit_logs_tenant ON audit_logs (tenant_id)".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE INDEX idx_audit_logs_tenant ON audit_logs (tenant_id)".to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE INDEX idx_audit_logs_resource ON audit_logs (resource_type, resource_id)".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE INDEX idx_audit_logs_resource ON audit_logs (resource_type, resource_id)"
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
use sea_orm::Statement;
|
||||
use sea_orm::DatabaseBackend;
|
||||
use sea_orm::Statement;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// Recreate unique indexes on roles and permissions to include soft-delete awareness.
|
||||
#[derive(DeriveMigrationName)]
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 修复 settings 表唯一索引:原索引使用 scope_id 列,当 scope_id 为 NULL 时
|
||||
/// PostgreSQL B-tree 不认为两行重复(NULL != NULL),导致可插入重复数据。
|
||||
/// 修复方案:使用 COALESCE(scope_id, '00000000-0000-0000-0000-000000000000')
|
||||
/// 将 NULL 转为固定 UUID,使索引能正确约束 NULL scope_id 的行。
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 删除旧索引
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"DROP INDEX IF EXISTS idx_settings_scope_key".to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 创建新索引,使用 COALESCE 处理 NULL scope_id
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'), setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 清理可能已存在的重复数据(保留每组最新的一条)
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
DELETE FROM settings a USING settings b
|
||||
WHERE a.id < b.id
|
||||
AND a.tenant_id = b.tenant_id
|
||||
AND a.scope = b.scope
|
||||
AND a.setting_key = b.setting_key
|
||||
AND a.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND COALESCE(a.scope_id, '00000000-0000-0000-0000-000000000000') = COALESCE(b.scope_id, '00000000-0000-0000-0000-000000000000')
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 回滚:删除新索引,恢复旧索引
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"DROP INDEX IF EXISTS idx_settings_scope_key".to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, scope_id, setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,11 @@ impl MigrationTrait for Migration {
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("event_type")).string_len(200).not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("event_type"))
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("payload")).json().null())
|
||||
.col(ColumnDef::new(Alias::new("correlation_id")).uuid().null())
|
||||
.col(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, FromRef, Query, State};
|
||||
use axum::response::Json;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -35,8 +35,7 @@ where
|
||||
let page_size = params.page_size.unwrap_or(20).min(100);
|
||||
let tenant_id = ctx.tenant_id;
|
||||
|
||||
let mut q = audit_log::Entity::find()
|
||||
.filter(audit_log::Column::TenantId.eq(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()));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::response::Json;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -6,14 +6,9 @@ use utoipa::openapi::OpenApiBuilder;
|
||||
///
|
||||
/// 返回 OpenAPI 3.0 规范 JSON 文档
|
||||
pub async fn openapi_spec() -> Json<Value> {
|
||||
let mut info = utoipa::openapi::Info::new(
|
||||
"ERP Platform API",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
let mut info = utoipa::openapi::Info::new("ERP Platform API", env!("CARGO_PKG_VERSION"));
|
||||
info.description = Some("ERP 平台底座 REST API 文档".to_string());
|
||||
|
||||
let spec = OpenApiBuilder::new()
|
||||
.info(info)
|
||||
.build();
|
||||
let spec = OpenApiBuilder::new().info(info).build();
|
||||
Json(serde_json::to_value(spec).unwrap_or_default())
|
||||
}
|
||||
|
||||
@@ -7,13 +7,11 @@ mod state;
|
||||
|
||||
/// OpenAPI 规范定义(预留,未来可通过 utoipa derive 合并各模块 schema)。
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "ERP Platform API",
|
||||
version = "0.1.0",
|
||||
description = "ERP 平台底座 REST API 文档"
|
||||
)
|
||||
)]
|
||||
#[openapi(info(
|
||||
title = "ERP Platform API",
|
||||
version = "0.1.0",
|
||||
description = "ERP 平台底座 REST API 文档"
|
||||
))]
|
||||
#[allow(dead_code)]
|
||||
struct ApiDoc;
|
||||
|
||||
@@ -38,13 +36,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(&config.log.level)),
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.log.level)),
|
||||
)
|
||||
.json()
|
||||
.init();
|
||||
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "ERP Server starting...");
|
||||
tracing::info!(
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
"ERP Server starting..."
|
||||
);
|
||||
|
||||
// Connect to database
|
||||
let db = db::connect(&config.database).await?;
|
||||
@@ -116,19 +116,35 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Initialize auth module
|
||||
let auth_module = erp_auth::AuthModule::new();
|
||||
tracing::info!(module = auth_module.name(), version = auth_module.version(), "Auth module initialized");
|
||||
tracing::info!(
|
||||
module = auth_module.name(),
|
||||
version = auth_module.version(),
|
||||
"Auth module initialized"
|
||||
);
|
||||
|
||||
// Initialize config module
|
||||
let config_module = erp_config::ConfigModule::new();
|
||||
tracing::info!(module = config_module.name(), version = config_module.version(), "Config module initialized");
|
||||
tracing::info!(
|
||||
module = config_module.name(),
|
||||
version = config_module.version(),
|
||||
"Config module initialized"
|
||||
);
|
||||
|
||||
// Initialize workflow module
|
||||
let workflow_module = erp_workflow::WorkflowModule::new();
|
||||
tracing::info!(module = workflow_module.name(), version = workflow_module.version(), "Workflow module initialized");
|
||||
tracing::info!(
|
||||
module = workflow_module.name(),
|
||||
version = workflow_module.version(),
|
||||
"Workflow module initialized"
|
||||
);
|
||||
|
||||
// Initialize message module
|
||||
let message_module = erp_message::MessageModule::new();
|
||||
tracing::info!(module = message_module.name(), version = message_module.version(), "Message module initialized");
|
||||
tracing::info!(
|
||||
module = message_module.name(),
|
||||
version = message_module.version(),
|
||||
"Message module initialized"
|
||||
);
|
||||
|
||||
// Initialize module registry and register modules
|
||||
let registry = ModuleRegistry::new()
|
||||
@@ -136,7 +152,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
.register(config_module)
|
||||
.register(workflow_module)
|
||||
.register(message_module);
|
||||
tracing::info!(module_count = registry.modules().len(), "Modules registered");
|
||||
tracing::info!(
|
||||
module_count = registry.modules().len(),
|
||||
"Modules registered"
|
||||
);
|
||||
|
||||
// Register event handlers
|
||||
registry.register_handlers(&event_bus);
|
||||
@@ -182,7 +201,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
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))
|
||||
.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,
|
||||
|
||||
@@ -15,7 +15,8 @@ struct RateLimitResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// 限流参数。
|
||||
/// 限流参数(预留配置化扩展)。
|
||||
#[allow(dead_code)]
|
||||
pub struct RateLimitConfig {
|
||||
/// 窗口内最大请求数。
|
||||
pub max_requests: u64,
|
||||
@@ -74,11 +75,7 @@ async fn apply_rate_limit(
|
||||
}
|
||||
};
|
||||
|
||||
let count: i64 = match redis::cmd("INCR")
|
||||
.arg(&key)
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
{
|
||||
let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis INCR 失败,跳过限流");
|
||||
|
||||
@@ -2,15 +2,14 @@ use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, ConnectionTrait,
|
||||
PaginatorTrait,
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::NodeType;
|
||||
use crate::engine::expression::ExpressionEvaluator;
|
||||
use crate::engine::model::FlowGraph;
|
||||
use crate::entity::{token, process_instance, task};
|
||||
use crate::entity::{process_instance, task, token};
|
||||
use crate::error::{WorkflowError, WorkflowResult};
|
||||
|
||||
/// Token 驱动的流程执行引擎。
|
||||
@@ -92,11 +91,16 @@ impl FlowExecutor {
|
||||
let mut active: token::ActiveModel = current_token.into();
|
||||
active.status = Set("consumed".to_string());
|
||||
active.consumed_at = Set(Some(Utc::now()));
|
||||
active.update(txn).await.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
active
|
||||
.update(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// 获取当前节点的出边
|
||||
let outgoing = graph.get_outgoing_edges(&node_id);
|
||||
let current_node = graph.nodes.get(&node_id)
|
||||
let current_node = graph
|
||||
.nodes
|
||||
.get(&node_id)
|
||||
.ok_or_else(|| WorkflowError::InvalidDiagram(format!("节点不存在: {node_id}")))?;
|
||||
|
||||
match current_node.node_type {
|
||||
@@ -177,11 +181,9 @@ impl FlowExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
let target = matched_target
|
||||
.or(default_target)
|
||||
.ok_or_else(|| WorkflowError::ExpressionError(
|
||||
"排他网关没有匹配的条件分支".to_string(),
|
||||
))?;
|
||||
let target = matched_target.or(default_target).ok_or_else(|| {
|
||||
WorkflowError::ExpressionError("排他网关没有匹配的条件分支".to_string())
|
||||
})?;
|
||||
|
||||
Self::create_token_at_node(instance_id, tenant_id, target, graph, variables, txn).await
|
||||
}
|
||||
@@ -219,136 +221,139 @@ impl FlowExecutor {
|
||||
graph: &'a FlowGraph,
|
||||
variables: &'a HashMap<String, serde_json::Value>,
|
||||
txn: &'a impl ConnectionTrait,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = WorkflowResult<Vec<Uuid>>> + Send + 'a>> {
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = WorkflowResult<Vec<Uuid>>> + Send + 'a>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
let node = graph.nodes.get(node_id)
|
||||
.ok_or_else(|| WorkflowError::InvalidDiagram(format!("节点不存在: {node_id}")))?;
|
||||
let node = graph
|
||||
.nodes
|
||||
.get(node_id)
|
||||
.ok_or_else(|| WorkflowError::InvalidDiagram(format!("节点不存在: {node_id}")))?;
|
||||
|
||||
match node.node_type {
|
||||
NodeType::EndEvent => {
|
||||
// 到达 EndEvent,不创建新 token
|
||||
// 检查实例是否所有 token 都完成
|
||||
Self::check_instance_completion(instance_id, tenant_id, txn).await?;
|
||||
Ok(vec![])
|
||||
}
|
||||
NodeType::ParallelGateway
|
||||
if Self::is_join_gateway(node_id, graph) =>
|
||||
{
|
||||
// 并行网关汇合:等待所有入边 token 到达
|
||||
Self::handle_join_gateway(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
node_id,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
NodeType::ServiceTask => {
|
||||
// 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(
|
||||
match node.node_type {
|
||||
NodeType::EndEvent => {
|
||||
// 到达 EndEvent,不创建新 token
|
||||
// 检查实例是否所有 token 都完成
|
||||
Self::check_instance_completion(instance_id, tenant_id, txn).await?;
|
||||
Ok(vec![])
|
||||
}
|
||||
NodeType::ParallelGateway if Self::is_join_gateway(node_id, graph) => {
|
||||
// 并行网关汇合:等待所有入边 token 到达
|
||||
Self::handle_join_gateway(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
&edge.target,
|
||||
node_id,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await?;
|
||||
new_tokens.extend(tokens);
|
||||
}
|
||||
Ok(new_tokens)
|
||||
}
|
||||
_ => {
|
||||
// UserTask / 网关(分支)等:创建活跃 token
|
||||
let new_token_id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
let system_user = uuid::Uuid::nil();
|
||||
|
||||
let token_model = token::ActiveModel {
|
||||
id: Set(new_token_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
node_id: Set(node_id.to_string()),
|
||||
status: Set("active".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(None),
|
||||
};
|
||||
token_model
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
}
|
||||
NodeType::ServiceTask => {
|
||||
// ServiceTask 自动执行:当前阶段自动跳过(直接推进到后继节点)
|
||||
// 创建一个立即消费的 token 记录(用于审计追踪)
|
||||
let now = Utc::now();
|
||||
let system_user = uuid::Uuid::nil();
|
||||
let auto_token_id = Uuid::now_v7();
|
||||
|
||||
// UserTask: 同时创建 task 记录
|
||||
if node.node_type == NodeType::UserTask {
|
||||
let task_model = task::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
let token_model = token::ActiveModel {
|
||||
id: Set(auto_token_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
token_id: Set(new_token_id),
|
||||
node_id: Set(node_id.to_string()),
|
||||
node_name: Set(Some(node.name.clone())),
|
||||
assignee_id: Set(node.assignee_id),
|
||||
candidate_groups: Set(node.candidate_groups.as_ref()
|
||||
.map(|g| serde_json::to_value(g).unwrap_or_default())),
|
||||
status: Set("pending".to_string()),
|
||||
outcome: Set(None),
|
||||
form_data: Set(None),
|
||||
due_date: Set(None),
|
||||
completed_at: Set(None),
|
||||
status: Set("consumed".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Uuid::nil()),
|
||||
updated_by: Set(Uuid::nil()),
|
||||
created_by: Set(system_user),
|
||||
updated_by: Set(system_user),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
consumed_at: Set(Some(now)),
|
||||
};
|
||||
task_model
|
||||
token_model
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(vec![new_token_id])
|
||||
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
|
||||
let new_token_id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
let system_user = uuid::Uuid::nil();
|
||||
|
||||
let token_model = token::ActiveModel {
|
||||
id: Set(new_token_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
node_id: Set(node_id.to_string()),
|
||||
status: Set("active".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(None),
|
||||
};
|
||||
token_model
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// UserTask: 同时创建 task 记录
|
||||
if node.node_type == NodeType::UserTask {
|
||||
let task_model = task::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
token_id: Set(new_token_id),
|
||||
node_id: Set(node_id.to_string()),
|
||||
node_name: Set(Some(node.name.clone())),
|
||||
assignee_id: Set(node.assignee_id),
|
||||
candidate_groups: Set(node
|
||||
.candidate_groups
|
||||
.as_ref()
|
||||
.map(|g| serde_json::to_value(g).unwrap_or_default())),
|
||||
status: Set("pending".to_string()),
|
||||
outcome: Set(None),
|
||||
form_data: Set(None),
|
||||
due_date: Set(None),
|
||||
completed_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Uuid::nil()),
|
||||
updated_by: Set(Uuid::nil()),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
task_model
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(vec![new_token_id])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -445,7 +450,10 @@ impl FlowExecutor {
|
||||
active.status = Set("completed".to_string());
|
||||
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()))?;
|
||||
active
|
||||
.update(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// 写入完成事件到 outbox,由 relay 广播
|
||||
let now = Utc::now();
|
||||
@@ -461,7 +469,10 @@ impl FlowExecutor {
|
||||
created_at: Set(now),
|
||||
published_at: Set(None),
|
||||
};
|
||||
outbox_event.insert(txn).await.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
outbox_event
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -18,7 +18,10 @@ impl ExpressionEvaluator {
|
||||
/// 求值单个条件表达式。
|
||||
///
|
||||
/// 表达式格式: `{left} {op} {right}` 或复合表达式 `{expr1} && {expr2}`
|
||||
pub fn eval(expr: &str, variables: &HashMap<String, serde_json::Value>) -> WorkflowResult<bool> {
|
||||
pub fn eval(
|
||||
expr: &str,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
) -> WorkflowResult<bool> {
|
||||
let expr = expr.trim();
|
||||
|
||||
// 处理逻辑 OR
|
||||
@@ -72,7 +75,10 @@ impl ExpressionEvaluator {
|
||||
}
|
||||
|
||||
/// 求值单个比较表达式。
|
||||
fn eval_comparison(expr: &str, variables: &HashMap<String, serde_json::Value>) -> WorkflowResult<bool> {
|
||||
fn eval_comparison(
|
||||
expr: &str,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
) -> WorkflowResult<bool> {
|
||||
let operators = [">=", "<=", "!=", "==", ">", "<"];
|
||||
|
||||
for op in &operators {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod expression;
|
||||
pub mod executor;
|
||||
pub mod expression;
|
||||
pub mod model;
|
||||
pub mod parser;
|
||||
pub mod timeout;
|
||||
|
||||
@@ -75,13 +75,16 @@ impl FlowGraph {
|
||||
}
|
||||
|
||||
for e in edges {
|
||||
graph.edges.insert(e.id.clone(), FlowEdge {
|
||||
id: e.id.clone(),
|
||||
source: e.source.clone(),
|
||||
target: e.target.clone(),
|
||||
condition: e.condition.clone(),
|
||||
label: e.label.clone(),
|
||||
});
|
||||
graph.edges.insert(
|
||||
e.id.clone(),
|
||||
FlowEdge {
|
||||
id: e.id.clone(),
|
||||
source: e.source.clone(),
|
||||
target: e.target.clone(),
|
||||
condition: e.condition.clone(),
|
||||
label: e.label.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(out) = graph.outgoing.get_mut(&e.source) {
|
||||
out.push(e.id.clone());
|
||||
|
||||
@@ -10,7 +10,10 @@ pub fn parse_and_validate(nodes: &[NodeDef], edges: &[EdgeDef]) -> WorkflowResul
|
||||
}
|
||||
|
||||
// 检查恰好 1 个 StartEvent
|
||||
let start_count = nodes.iter().filter(|n| n.node_type == NodeType::StartEvent).count();
|
||||
let start_count = nodes
|
||||
.iter()
|
||||
.filter(|n| n.node_type == NodeType::StartEvent)
|
||||
.count();
|
||||
if start_count == 0 {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"流程图必须包含一个开始事件".to_string(),
|
||||
@@ -23,7 +26,10 @@ pub fn parse_and_validate(nodes: &[NodeDef], edges: &[EdgeDef]) -> WorkflowResul
|
||||
}
|
||||
|
||||
// 检查至少 1 个 EndEvent
|
||||
let end_count = nodes.iter().filter(|n| n.node_type == NodeType::EndEvent).count();
|
||||
let end_count = nodes
|
||||
.iter()
|
||||
.filter(|n| n.node_type == NodeType::EndEvent)
|
||||
.count();
|
||||
if end_count == 0 {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"流程图必须包含至少一个结束事件".to_string(),
|
||||
@@ -31,8 +37,7 @@ pub fn parse_and_validate(nodes: &[NodeDef], edges: &[EdgeDef]) -> WorkflowResul
|
||||
}
|
||||
|
||||
// 检查节点 ID 唯一性
|
||||
let node_ids: std::collections::HashSet<&str> =
|
||||
nodes.iter().map(|n| n.id.as_str()).collect();
|
||||
let node_ids: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
|
||||
if node_ids.len() != nodes.len() {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"节点 ID 不能重复".to_string(),
|
||||
@@ -101,7 +106,8 @@ pub fn parse_and_validate(nodes: &[NodeDef], edges: &[EdgeDef]) -> WorkflowResul
|
||||
}
|
||||
// 排他网关的出边应该有条件(第一条可以无条件作为默认分支)
|
||||
if node.node_type == NodeType::ExclusiveGateway && out.len() > 1 {
|
||||
let with_condition: Vec<_> = out.iter().filter(|e| e.condition.is_some()).collect();
|
||||
let with_condition: Vec<_> =
|
||||
out.iter().filter(|e| e.condition.is_some()).collect();
|
||||
if with_condition.is_empty() {
|
||||
return Err(WorkflowError::InvalidDiagram(format!(
|
||||
"排他网关 '{}' 有多条出边但没有条件表达式",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod process_definition;
|
||||
pub mod process_instance;
|
||||
pub mod token;
|
||||
pub mod task;
|
||||
pub mod process_variable;
|
||||
pub mod task;
|
||||
pub mod token;
|
||||
|
||||
@@ -94,8 +94,7 @@ where
|
||||
{
|
||||
require_permission(&ctx, "workflow.update")?;
|
||||
|
||||
let resp =
|
||||
DefinitionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
let resp = DefinitionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
@@ -111,14 +110,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "workflow.publish")?;
|
||||
|
||||
let resp = DefinitionService::publish(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
let resp =
|
||||
DefinitionService::publish(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user