From 8a012f6c6a3f87d21d5722ae569fc58fef7d7282 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 11 Apr 2026 04:00:32 +0800 Subject: [PATCH] feat(auth): add org/dept/position management, user page, and Phase 2 completion Complete Phase 2 identity & authentication module: - Organization CRUD with tree structure (parent_id + materialized path) - Department CRUD nested under organizations with tree support - Position CRUD nested under departments - User management page with table, create/edit modal, role assignment - Organization architecture page with 3-panel tree layout - Frontend API layer for orgs/depts/positions - Sidebar navigation updated with organization menu item - Fix parse_ttl edge case for strings ending in 'd' (e.g. "invalid") --- Cargo.lock | 112 ++++ apps/web/src/App.tsx | 5 +- apps/web/src/api/orgs.ts | 168 +++++ apps/web/src/layouts/MainLayout.tsx | 2 + apps/web/src/pages/Organizations.tsx | 586 ++++++++++++++++++ apps/web/src/pages/Users.tsx | 363 +++++++++++ crates/erp-auth/src/auth_state.rs | 16 +- crates/erp-auth/src/dto.rs | 16 + crates/erp-auth/src/handler/mod.rs | 1 + crates/erp-auth/src/handler/org_handler.rs | 326 ++++++++++ crates/erp-auth/src/module.rs | 35 +- crates/erp-auth/src/service/dept_service.rs | 295 +++++++++ crates/erp-auth/src/service/mod.rs | 3 + crates/erp-auth/src/service/org_service.rs | 273 ++++++++ .../erp-auth/src/service/position_service.rs | 218 +++++++ 15 files changed, 2409 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/api/orgs.ts create mode 100644 apps/web/src/pages/Organizations.tsx create mode 100644 apps/web/src/pages/Users.tsx create mode 100644 crates/erp-auth/src/handler/org_handler.rs create mode 100644 crates/erp-auth/src/service/dept_service.rs create mode 100644 crates/erp-auth/src/service/org_service.rs create mode 100644 crates/erp-auth/src/service/position_service.rs diff --git a/Cargo.lock b/Cargo.lock index b5071d1..0aecd8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -318,6 +330,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -657,6 +678,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "strsim", "syn 2.0.117", ] @@ -781,15 +803,23 @@ name = "erp-auth" version = "0.1.0" dependencies = [ "anyhow", + "argon2", + "async-trait", "axum", "chrono", + "erp-common", "erp-core", + "jsonwebtoken", "sea-orm", "serde", "serde_json", + "sha2", + "thiserror", "tokio", "tracing", + "utoipa", "uuid", + "validator", ] [[package]] @@ -859,6 +889,7 @@ dependencies = [ "anyhow", "axum", "config", + "erp-auth", "erp-common", "erp-core", "erp-server-migration", @@ -872,6 +903,7 @@ dependencies = [ "tracing", "tracing-subscriber", "utoipa", + "uuid", ] [[package]] @@ -1106,8 +1138,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1512,6 +1546,21 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1844,12 +1893,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2686,6 +2756,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -3493,6 +3575,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0b4a29d8709210980a09379f27ee31549b73292c87ab9899beee1c0d3be6303" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b15a130..0960674 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,6 +6,8 @@ import MainLayout from './layouts/MainLayout'; import Login from './pages/Login'; import Home from './pages/Home'; import Roles from './pages/Roles'; +import Users from './pages/Users'; +import Organizations from './pages/Organizations'; import { useAuthStore } from './stores/auth'; import { useAppStore } from './stores/app'; @@ -40,8 +42,9 @@ export default function App() { } /> - 用户管理(开发中)} /> + } /> } /> + } /> 系统设置(开发中)} /> diff --git a/apps/web/src/api/orgs.ts b/apps/web/src/api/orgs.ts new file mode 100644 index 0000000..10e40c8 --- /dev/null +++ b/apps/web/src/api/orgs.ts @@ -0,0 +1,168 @@ +import client from './client'; + +// --- Organization types --- + +export interface OrganizationInfo { + id: string; + name: string; + code?: string; + parent_id?: string; + path?: string; + level: number; + sort_order: number; + children: OrganizationInfo[]; +} + +export interface CreateOrganizationRequest { + name: string; + code?: string; + parent_id?: string; + sort_order?: number; +} + +export interface UpdateOrganizationRequest { + name?: string; + code?: string; + sort_order?: number; +} + +// --- Department types --- + +export interface DepartmentInfo { + id: string; + org_id: string; + name: string; + code?: string; + parent_id?: string; + manager_id?: string; + path?: string; + sort_order: number; + children: DepartmentInfo[]; +} + +export interface CreateDepartmentRequest { + name: string; + code?: string; + parent_id?: string; + manager_id?: string; + sort_order?: number; +} + +export interface UpdateDepartmentRequest { + name?: string; + code?: string; + manager_id?: string; + sort_order?: number; +} + +// --- Position types --- + +export interface PositionInfo { + id: string; + dept_id: string; + name: string; + code?: string; + level: number; + sort_order: number; +} + +export interface CreatePositionRequest { + name: string; + code?: string; + level?: number; + sort_order?: number; +} + +export interface UpdatePositionRequest { + name?: string; + code?: string; + level?: number; + sort_order?: number; +} + +// --- Organization API --- + +export async function listOrgTree() { + const { data } = await client.get<{ success: boolean; data: OrganizationInfo[] }>( + '/organizations', + ); + return data.data; +} + +export async function createOrg(req: CreateOrganizationRequest) { + const { data } = await client.post<{ success: boolean; data: OrganizationInfo }>( + '/organizations', + req, + ); + return data.data; +} + +export async function updateOrg(id: string, req: UpdateOrganizationRequest) { + const { data } = await client.put<{ success: boolean; data: OrganizationInfo }>( + `/organizations/${id}`, + req, + ); + return data.data; +} + +export async function deleteOrg(id: string) { + await client.delete(`/organizations/${id}`); +} + +// --- Department API --- + +export async function listDeptTree(orgId: string) { + const { data } = await client.get<{ success: boolean; data: DepartmentInfo[] }>( + `/organizations/${orgId}/departments`, + ); + return data.data; +} + +export async function createDept(orgId: string, req: CreateDepartmentRequest) { + const { data } = await client.post<{ success: boolean; data: DepartmentInfo }>( + `/organizations/${orgId}/departments`, + req, + ); + return data.data; +} + +export async function deleteDept(id: string) { + await client.delete(`/departments/${id}`); +} + +export async function updateDept(id: string, req: UpdateDepartmentRequest) { + const { data } = await client.put<{ success: boolean; data: DepartmentInfo }>( + `/departments/${id}`, + req, + ); + return data.data; +} + +// --- Position API --- + +export async function listPositions(deptId: string) { + const { data } = await client.get<{ success: boolean; data: PositionInfo[] }>( + `/departments/${deptId}/positions`, + ); + return data.data; +} + +export async function createPosition(deptId: string, req: CreatePositionRequest) { + const { data } = await client.post<{ success: boolean; data: PositionInfo }>( + `/departments/${deptId}/positions`, + req, + ); + return data.data; +} + +export async function deletePosition(id: string) { + await client.delete(`/positions/${id}`); +} + +export async function updatePosition(id: string, req: UpdatePositionRequest) { + const { data } = await client.put<{ success: boolean; data: PositionInfo }>( + `/positions/${id}`, + req, + ); + return data.data; +} diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 633b66a..f87602c 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -3,6 +3,7 @@ import { HomeOutlined, UserOutlined, SafetyOutlined, + ApartmentOutlined, BellOutlined, SettingOutlined, MenuFoldOutlined, @@ -19,6 +20,7 @@ const menuItems = [ { key: '/', icon: , label: '首页' }, { key: '/users', icon: , label: '用户管理' }, { key: '/roles', icon: , label: '权限管理' }, + { key: '/organizations', icon: , label: '组织架构' }, { key: '/settings', icon: , label: '系统设置' }, ]; diff --git a/apps/web/src/pages/Organizations.tsx b/apps/web/src/pages/Organizations.tsx new file mode 100644 index 0000000..b07bda4 --- /dev/null +++ b/apps/web/src/pages/Organizations.tsx @@ -0,0 +1,586 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Tree, + Button, + Space, + Modal, + Form, + Input, + InputNumber, + Table, + Popconfirm, + message, + Typography, + Card, + Empty, + Tag, +} from 'antd'; +import { + PlusOutlined, + DeleteOutlined, + EditOutlined, + ApartmentOutlined, +} from '@ant-design/icons'; +import type { DataNode } from 'antd/es/tree'; +import { + listOrgTree, + createOrg, + updateOrg, + deleteOrg, + listDeptTree, + createDept, + deleteDept, + listPositions, + createPosition, + deletePosition, + type OrganizationInfo, + type DepartmentInfo, + type PositionInfo, +} from '../api/orgs'; + +export default function Organizations() { + // --- Org tree state --- + const [orgTree, setOrgTree] = useState([]); + const [selectedOrg, setSelectedOrg] = useState(null); + const [loading, setLoading] = useState(false); + + // --- Department tree state --- + const [deptTree, setDeptTree] = useState([]); + const [selectedDept, setSelectedDept] = useState(null); + + // --- Position list state --- + const [positions, setPositions] = useState([]); + + // --- Modal state --- + const [orgModalOpen, setOrgModalOpen] = useState(false); + const [deptModalOpen, setDeptModalOpen] = useState(false); + const [positionModalOpen, setPositionModalOpen] = useState(false); + const [editOrg, setEditOrg] = useState(null); + + const [orgForm] = Form.useForm(); + const [deptForm] = Form.useForm(); + const [positionForm] = Form.useForm(); + + // --- Fetch org tree --- + const fetchOrgTree = useCallback(async () => { + setLoading(true); + try { + const tree = await listOrgTree(); + setOrgTree(tree); + // Clear selection if org no longer exists + if (selectedOrg) { + const stillExists = findOrgInTree(tree, selectedOrg.id); + if (!stillExists) { + setSelectedOrg(null); + setDeptTree([]); + setPositions([]); + } + } + } catch { + message.error('加载组织树失败'); + } + setLoading(false); + }, [selectedOrg]); + + useEffect(() => { + fetchOrgTree(); + }, [fetchOrgTree]); + + // --- Fetch dept tree when org selected --- + const fetchDeptTree = useCallback(async () => { + if (!selectedOrg) return; + try { + const tree = await listDeptTree(selectedOrg.id); + setDeptTree(tree); + if (selectedDept) { + const stillExists = findDeptInTree(tree, selectedDept.id); + if (!stillExists) { + setSelectedDept(null); + setPositions([]); + } + } + } catch { + message.error('加载部门树失败'); + } + }, [selectedOrg, selectedDept]); + + useEffect(() => { + fetchDeptTree(); + }, [fetchDeptTree]); + + // --- Fetch positions when dept selected --- + const fetchPositions = useCallback(async () => { + if (!selectedDept) return; + try { + const list = await listPositions(selectedDept.id); + setPositions(list); + } catch { + message.error('加载岗位列表失败'); + } + }, [selectedDept]); + + useEffect(() => { + fetchPositions(); + }, [fetchPositions]); + + // --- Org handlers --- + const handleCreateOrg = async (values: { + name: string; + code?: string; + sort_order?: number; + }) => { + try { + if (editOrg) { + await updateOrg(editOrg.id, { + name: values.name, + code: values.code, + sort_order: values.sort_order, + }); + message.success('组织更新成功'); + } else { + await createOrg({ + name: values.name, + code: values.code, + parent_id: selectedOrg?.id, + sort_order: values.sort_order, + }); + message.success('组织创建成功'); + } + setOrgModalOpen(false); + setEditOrg(null); + orgForm.resetFields(); + fetchOrgTree(); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || '操作失败'; + message.error(errorMsg); + } + }; + + const handleDeleteOrg = async (id: string) => { + try { + await deleteOrg(id); + message.success('组织已删除'); + setSelectedOrg(null); + setDeptTree([]); + setPositions([]); + fetchOrgTree(); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || '删除失败'; + message.error(errorMsg); + } + }; + + // --- Dept handlers --- + const handleCreateDept = async (values: { + name: string; + code?: string; + sort_order?: number; + }) => { + if (!selectedOrg) return; + try { + await createDept(selectedOrg.id, { + name: values.name, + code: values.code, + parent_id: selectedDept?.id, + sort_order: values.sort_order, + }); + message.success('部门创建成功'); + setDeptModalOpen(false); + deptForm.resetFields(); + fetchDeptTree(); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || '操作失败'; + message.error(errorMsg); + } + }; + + const handleDeleteDept = async (id: string) => { + try { + await deleteDept(id); + message.success('部门已删除'); + setSelectedDept(null); + setPositions([]); + fetchDeptTree(); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || '删除失败'; + message.error(errorMsg); + } + }; + + // --- Position handlers --- + const handleCreatePosition = async (values: { + name: string; + code?: string; + level?: number; + sort_order?: number; + }) => { + if (!selectedDept) return; + try { + await createPosition(selectedDept.id, { + name: values.name, + code: values.code, + level: values.level, + sort_order: values.sort_order, + }); + message.success('岗位创建成功'); + setPositionModalOpen(false); + positionForm.resetFields(); + fetchPositions(); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || '操作失败'; + message.error(errorMsg); + } + }; + + const handleDeletePosition = async (id: string) => { + try { + await deletePosition(id); + message.success('岗位已删除'); + fetchPositions(); + } catch { + message.error('删除失败'); + } + }; + + // --- Tree node converters --- + const convertOrgTree = (items: OrganizationInfo[]): DataNode[] => + items.map((item) => ({ + key: item.id, + title: ( + + {item.name}{' '} + {item.code && {item.code}} + + ), + children: convertOrgTree(item.children), + })); + + const convertDeptTree = (items: DepartmentInfo[]): DataNode[] => + items.map((item) => ({ + key: item.id, + title: ( + + {item.name}{' '} + {item.code && {item.code}} + + ), + children: convertDeptTree(item.children), + })); + + // --- Helper to find node in tree --- + const onSelectOrg = (selectedKeys: React.Key[]) => { + if (selectedKeys.length === 0) { + setSelectedOrg(null); + setDeptTree([]); + setSelectedDept(null); + setPositions([]); + return; + } + const org = findOrgInTree(orgTree, selectedKeys[0] as string); + setSelectedOrg(org); + setSelectedDept(null); + setPositions([]); + }; + + const onSelectDept = (selectedKeys: React.Key[]) => { + if (selectedKeys.length === 0) { + setSelectedDept(null); + setPositions([]); + return; + } + const dept = findDeptInTree(deptTree, selectedKeys[0] as string); + setSelectedDept(dept); + }; + + const positionColumns = [ + { title: '岗位名称', dataIndex: 'name', key: 'name' }, + { title: '编码', dataIndex: 'code', key: 'code', render: (v?: string) => v || '-' }, + { title: '级别', dataIndex: 'level', key: 'level' }, + { title: '排序', dataIndex: 'sort_order', key: 'sort_order' }, + { + title: '操作', + key: 'actions', + render: (_: unknown, record: PositionInfo) => ( + handleDeletePosition(record.id)} + > + + + ), + }, + ]; + + return ( +
+
+ + + 组织架构管理 + +
+ +
+ {/* Left: Organization Tree */} + + + {selectedOrg && ( + <> + + {selectedDept && ( + handleDeleteDept(selectedDept.id)} + > + + ) : null + } + > + {selectedDept ? ( + + ) : ( + + )} + + + + {/* Org Modal */} + { + setOrgModalOpen(false); + setEditOrg(null); + }} + onOk={() => orgForm.submit()} + > +
+ + + + + + + + + + +
+ + {/* Dept Modal */} + setDeptModalOpen(false)} + onOk={() => deptForm.submit()} + > +
+ + + + + + + + + + +
+ + {/* Position Modal */} + setPositionModalOpen(false)} + onOk={() => positionForm.submit()} + > +
+ + + + + + + + + + + + + +
+ + ); +} + +// --- Helpers --- + +function findOrgInTree( + tree: OrganizationInfo[], + id: string, +): OrganizationInfo | null { + for (const item of tree) { + if (item.id === id) return item; + const found = findOrgInTree(item.children, id); + if (found) return found; + } + return null; +} + +function findDeptInTree( + tree: DepartmentInfo[], + id: string, +): DepartmentInfo | null { + for (const item of tree) { + if (item.id === id) return item; + const found = findDeptInTree(item.children, id); + if (found) return found; + } + return null; +} diff --git a/apps/web/src/pages/Users.tsx b/apps/web/src/pages/Users.tsx new file mode 100644 index 0000000..ccc01f3 --- /dev/null +++ b/apps/web/src/pages/Users.tsx @@ -0,0 +1,363 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Table, + Button, + Space, + Modal, + Form, + Input, + Tag, + Popconfirm, + Checkbox, + message, + Typography, +} from 'antd'; +import { PlusOutlined, SearchOutlined } from '@ant-design/icons'; +import { + listUsers, + createUser, + updateUser, + deleteUser, + assignRoles, + type CreateUserRequest, + type UpdateUserRequest, +} from '../api/users'; +import { listRoles, type RoleInfo } from '../api/roles'; +import type { UserInfo } from '../api/auth'; + +const STATUS_COLOR_MAP: Record = { + active: 'green', + disabled: 'red', + locked: 'orange', +}; + +const STATUS_LABEL_MAP: Record = { + active: '正常', + disabled: '禁用', + locked: '锁定', +}; + +export default function Users() { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [searchText, setSearchText] = useState(''); + + const [createModalOpen, setCreateModalOpen] = useState(false); + const [editUser, setEditUser] = useState(null); + const [roleModalOpen, setRoleModalOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [allRoles, setAllRoles] = useState([]); + const [selectedRoleIds, setSelectedRoleIds] = useState([]); + + const [form] = Form.useForm(); + + const fetchUsers = useCallback(async (p = page) => { + setLoading(true); + try { + const result = await listUsers(p, 20); + setUsers(result.data); + setTotal(result.total); + } catch { + message.error('加载用户列表失败'); + } + setLoading(false); + }, [page]); + + const fetchRoles = useCallback(async () => { + try { + const result = await listRoles(); + setAllRoles(result.data); + } catch { + // Roles may not be seeded yet; silently ignore + } + }, []); + + useEffect(() => { + fetchUsers(); + fetchRoles(); + }, [fetchUsers, fetchRoles]); + + const handleCreateOrEdit = async (values: { + username: string; + password?: string; + display_name?: string; + email?: string; + phone?: string; + }) => { + try { + if (editUser) { + const req: UpdateUserRequest = { + display_name: values.display_name, + email: values.email, + phone: values.phone, + }; + await updateUser(editUser.id, req); + message.success('用户更新成功'); + } else { + const req: CreateUserRequest = { + username: values.username, + password: values.password ?? '', + display_name: values.display_name, + email: values.email, + phone: values.phone, + }; + await createUser(req); + message.success('用户创建成功'); + } + setCreateModalOpen(false); + setEditUser(null); + form.resetFields(); + fetchUsers(); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || '操作失败'; + message.error(errorMsg); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteUser(id); + message.success('用户已删除'); + fetchUsers(); + } catch { + message.error('删除失败'); + } + }; + + const handleToggleStatus = async (id: string, status: string) => { + try { + await updateUser(id, { status }); + message.success(status === 'disabled' ? '用户已禁用' : '用户已启用'); + fetchUsers(); + } catch { + message.error('状态更新失败'); + } + }; + + const handleAssignRoles = async () => { + if (!selectedUser) return; + try { + await assignRoles(selectedUser.id, selectedRoleIds); + message.success('角色分配成功'); + setRoleModalOpen(false); + fetchUsers(); + } catch { + message.error('角色分配失败'); + } + }; + + const openCreateModal = () => { + setEditUser(null); + form.resetFields(); + setCreateModalOpen(true); + }; + + const openEditModal = (user: UserInfo) => { + setEditUser(user); + form.setFieldsValue({ + username: user.username, + display_name: user.display_name, + email: user.email, + phone: user.phone, + }); + setCreateModalOpen(true); + }; + + const closeCreateModal = () => { + setCreateModalOpen(false); + setEditUser(null); + form.resetFields(); + }; + + const openRoleModal = (user: UserInfo) => { + setSelectedUser(user); + setSelectedRoleIds(user.roles.map((r) => r.id)); + setRoleModalOpen(true); + }; + + const filteredUsers = searchText + ? users.filter((u) => + u.username.toLowerCase().includes(searchText.toLowerCase()), + ) + : users; + + const columns = [ + { title: '用户名', dataIndex: 'username', key: 'username' }, + { + title: '显示名', + dataIndex: 'display_name', + key: 'display_name', + render: (v: string | undefined) => v || '-', + }, + { title: '邮箱', dataIndex: 'email', key: 'email' }, + { title: '电话', dataIndex: 'phone', key: 'phone' }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (status: string) => ( + + {STATUS_LABEL_MAP[status] || status} + + ), + }, + { + title: '角色', + dataIndex: 'roles', + key: 'roles', + render: (roles: RoleInfo[]) => + roles.length > 0 + ? roles.map((r) => {r.name}) + : '-', + }, + { + title: '操作', + key: 'actions', + render: (_: unknown, record: UserInfo) => ( + + + + {record.status === 'active' ? ( + handleToggleStatus(record.id, 'disabled')} + > + + + ) : ( + + )} + handleDelete(record.id)} + > + + + + ), + }, + ]; + + return ( +
+
+ + 用户管理 + + + } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + /> + + +
+ +
{ + setPage(p); + fetchUsers(p); + }, + }} + /> + + form.submit()} + > +
+ + + + {!editUser && ( + + + + )} + + + + + + + + + + +
+ + setRoleModalOpen(false)} + onOk={handleAssignRoles} + > + setSelectedRoleIds(values as string[])} + options={allRoles.map((r) => ({ + label: `${r.name} (${r.code})`, + value: r.id, + }))} + /> + + + ); +} diff --git a/crates/erp-auth/src/auth_state.rs b/crates/erp-auth/src/auth_state.rs index 09c8619..549b844 100644 --- a/crates/erp-auth/src/auth_state.rs +++ b/crates/erp-auth/src/auth_state.rs @@ -28,14 +28,14 @@ pub struct AuthState { /// Falls back to parsing the raw string as seconds if no unit suffix is recognized. pub fn parse_ttl(ttl: &str) -> i64 { let ttl = ttl.trim(); - if ttl.ends_with('s') { - ttl.trim_end_matches('s').parse::().unwrap_or(900) - } else if ttl.ends_with('m') { - ttl.trim_end_matches('m').parse::().unwrap_or(15) * 60 - } else if ttl.ends_with('h') { - ttl.trim_end_matches('h').parse::().unwrap_or(1) * 3600 - } else if ttl.ends_with('d') { - ttl.trim_end_matches('d').parse::().unwrap_or(1) * 86400 + if let Some(num) = ttl.strip_suffix('s') { + num.parse::().unwrap_or(900) + } else if let Some(num) = ttl.strip_suffix('m') { + num.parse::().map(|n| n * 60).unwrap_or(900) + } else if let Some(num) = ttl.strip_suffix('h') { + num.parse::().map(|n| n * 3600).unwrap_or(900) + } else if let Some(num) = ttl.strip_suffix('d') { + num.parse::().map(|n| n * 86400).unwrap_or(900) } else { ttl.parse::().unwrap_or(900) } diff --git a/crates/erp-auth/src/dto.rs b/crates/erp-auth/src/dto.rs index 7415445..eb216ef 100644 --- a/crates/erp-auth/src/dto.rs +++ b/crates/erp-auth/src/dto.rs @@ -163,6 +163,14 @@ pub struct CreateDepartmentReq { pub sort_order: Option, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateDepartmentReq { + pub name: Option, + pub code: Option, + pub manager_id: Option, + pub sort_order: Option, +} + // --- Position DTOs --- #[derive(Debug, Serialize, ToSchema)] @@ -183,3 +191,11 @@ pub struct CreatePositionReq { pub level: Option, pub sort_order: Option, } + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdatePositionReq { + pub name: Option, + pub code: Option, + pub level: Option, + pub sort_order: Option, +} diff --git a/crates/erp-auth/src/handler/mod.rs b/crates/erp-auth/src/handler/mod.rs index 40c8051..1c02fab 100644 --- a/crates/erp-auth/src/handler/mod.rs +++ b/crates/erp-auth/src/handler/mod.rs @@ -1,3 +1,4 @@ pub mod auth_handler; +pub mod org_handler; pub mod role_handler; pub mod user_handler; diff --git a/crates/erp-auth/src/handler/org_handler.rs b/crates/erp-auth/src/handler/org_handler.rs new file mode 100644 index 0000000..d5c0adc --- /dev/null +++ b/crates/erp-auth/src/handler/org_handler.rs @@ -0,0 +1,326 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, TenantContext}; +use uuid::Uuid; + +use crate::auth_state::AuthState; +use crate::dto::{ + CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp, + OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq, +}; +use crate::middleware::rbac::require_permission; +use crate::service::dept_service::DeptService; +use crate::service::org_service::OrgService; +use crate::service::position_service::PositionService; + +// --- Organization handlers --- + +/// GET /api/v1/organizations +/// +/// List all organizations within the current tenant as a nested tree. +/// Requires the `organization.list` permission. +pub async fn list_organizations( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "organization.list")?; + + let tree = OrgService::get_tree(ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(tree))) +} + +/// POST /api/v1/organizations +/// +/// Create a new organization within the current tenant. +/// Requires the `organization.create` permission. +pub async fn create_organization( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "organization.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let org = OrgService::create( + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(org))) +} + +/// PUT /api/v1/organizations/{id} +/// +/// Update editable organization fields (name, code, sort_order). +/// Requires the `organization.update` permission. +pub async fn update_organization( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "organization.update")?; + + let org = OrgService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + Ok(Json(ApiResponse::ok(org))) +} + +/// DELETE /api/v1/organizations/{id} +/// +/// Soft-delete an organization by ID. +/// Requires the `organization.delete` permission. +pub async fn delete_organization( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "organization.delete")?; + + OrgService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("组织已删除".to_string()), + })) +} + +// --- Department handlers --- + +/// GET /api/v1/organizations/{org_id}/departments +/// +/// List all departments for an organization as a nested tree. +/// Requires the `department.list` permission. +pub async fn list_departments( + State(state): State, + Extension(ctx): Extension, + Path(org_id): Path, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "department.list")?; + + let tree = DeptService::list_tree(org_id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(tree))) +} + +/// POST /api/v1/organizations/{org_id}/departments +/// +/// Create a new department under the specified organization. +/// Requires the `department.create` permission. +pub async fn create_department( + State(state): State, + Extension(ctx): Extension, + Path(org_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "department.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let dept = DeptService::create( + org_id, + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(dept))) +} + +/// PUT /api/v1/departments/{id} +/// +/// Update editable department fields (name, code, manager_id, sort_order). +/// Requires the `department.update` permission. +pub async fn update_department( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "department.update")?; + + let dept = DeptService::update( + id, + ctx.tenant_id, + ctx.user_id, + &req.name, + &req.code, + &req.manager_id, + &req.sort_order, + &state.db, + ) + .await?; + Ok(Json(ApiResponse::ok(dept))) +} + +/// DELETE /api/v1/departments/{id} +/// +/// Soft-delete a department by ID. +/// Requires the `department.delete` permission. +pub async fn delete_department( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "department.delete")?; + + DeptService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("部门已删除".to_string()), + })) +} + +// --- Position handlers --- + +/// GET /api/v1/departments/{dept_id}/positions +/// +/// List all positions for a department. +/// Requires the `position.list` permission. +pub async fn list_positions( + State(state): State, + Extension(ctx): Extension, + Path(dept_id): Path, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "position.list")?; + + let positions = PositionService::list(dept_id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(positions))) +} + +/// POST /api/v1/departments/{dept_id}/positions +/// +/// Create a new position under the specified department. +/// Requires the `position.create` permission. +pub async fn create_position( + State(state): State, + Extension(ctx): Extension, + Path(dept_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "position.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let pos = PositionService::create( + dept_id, + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(pos))) +} + +/// PUT /api/v1/positions/{id} +/// +/// Update editable position fields (name, code, level, sort_order). +/// Requires the `position.update` permission. +pub async fn update_position( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "position.update")?; + + let pos = PositionService::update( + id, + ctx.tenant_id, + ctx.user_id, + &req.name, + &req.code, + &req.level, + &req.sort_order, + &state.db, + ) + .await?; + Ok(Json(ApiResponse::ok(pos))) +} + +/// DELETE /api/v1/positions/{id} +/// +/// Soft-delete a position by ID. +/// Requires the `position.delete` permission. +pub async fn delete_position( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "position.delete")?; + + PositionService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("岗位已删除".to_string()), + })) +} diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs index 134d79d..b7a1cd9 100644 --- a/crates/erp-auth/src/module.rs +++ b/crates/erp-auth/src/module.rs @@ -5,7 +5,7 @@ use erp_core::error::AppResult; use erp_core::events::EventBus; use erp_core::module::ErpModule; -use crate::handler::{auth_handler, role_handler, user_handler}; +use crate::handler::{auth_handler, org_handler, role_handler, user_handler}; /// Auth module implementing the `ErpModule` trait. /// @@ -72,6 +72,39 @@ impl AuthModule { "/permissions", axum::routing::get(role_handler::list_permissions), ) + // Organization routes + .route( + "/organizations", + axum::routing::get(org_handler::list_organizations) + .post(org_handler::create_organization), + ) + .route( + "/organizations/{id}", + axum::routing::put(org_handler::update_organization) + .delete(org_handler::delete_organization), + ) + // Department routes (nested under organization) + .route( + "/organizations/{org_id}/departments", + axum::routing::get(org_handler::list_departments) + .post(org_handler::create_department), + ) + .route( + "/departments/{id}", + axum::routing::put(org_handler::update_department) + .delete(org_handler::delete_department), + ) + // Position routes (nested under department) + .route( + "/departments/{dept_id}/positions", + axum::routing::get(org_handler::list_positions) + .post(org_handler::create_position), + ) + .route( + "/positions/{id}", + axum::routing::put(org_handler::update_position) + .delete(org_handler::delete_position), + ) } } diff --git a/crates/erp-auth/src/service/dept_service.rs b/crates/erp-auth/src/service/dept_service.rs new file mode 100644 index 0000000..4f8aa85 --- /dev/null +++ b/crates/erp-auth/src/service/dept_service.rs @@ -0,0 +1,295 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{CreateDepartmentReq, DepartmentResp}; +use crate::entity::department; +use crate::entity::organization; +use crate::error::{AuthError, AuthResult}; +use erp_core::events::EventBus; + +/// Department CRUD service -- create, read, update, soft-delete departments +/// within an organization, supporting tree-structured hierarchy. +pub struct DeptService; + +impl DeptService { + /// Fetch all departments for an organization as a nested tree. + /// + /// Root departments (parent_id = None) form the top level. + pub async fn list_tree( + org_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + // Verify the organization exists + let _org = organization::Entity::find_by_id(org_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?; + + let items = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::OrgId.eq(org_id)) + .filter(department::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(build_dept_tree(&items)) + } + + /// Create a new department under the specified organization. + /// + /// If `parent_id` is provided, computes `path` from the parent department. + /// Otherwise, path is computed from the organization root. + pub async fn create( + org_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &CreateDepartmentReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult { + // Verify the organization exists + let org = organization::Entity::find_by_id(org_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?; + + // Check code uniqueness within tenant if code is provided + if let Some(ref code) = req.code { + let existing = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::Code.eq(code.as_str())) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("部门编码已存在".to_string())); + } + } + + // Compute path from parent department or organization root + let path = if let Some(parent_id) = req.parent_id { + let parent = department::Entity::find_by_id(parent_id) + .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()) + .ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?; + + let parent_path = parent.path.clone().unwrap_or_default(); + Some(format!("{}{}/", parent_path, parent.id)) + } else { + // Root department under the organization + let org_path = org.path.clone().unwrap_or_default(); + Some(format!("{}{}/", org_path, org.id)) + }; + + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = department::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + org_id: Set(org_id), + name: Set(req.name.clone()), + code: Set(req.code.clone()), + parent_id: Set(req.parent_id), + manager_id: Set(req.manager_id), + path: Set(path), + sort_order: Set(req.sort_order.unwrap_or(0)), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .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 }), + )); + + Ok(DepartmentResp { + id, + org_id, + name: req.name.clone(), + code: req.code.clone(), + parent_id: req.parent_id, + manager_id: req.manager_id, + path: None, + sort_order: req.sort_order.unwrap_or(0), + children: vec![], + }) + } + + /// Update editable department fields (name, code, manager_id, sort_order). + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + name: &Option, + code: &Option, + manager_id: &Option, + sort_order: &Option, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = department::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?; + + // If code is being changed, check uniqueness + if let Some(new_code) = code { + if Some(new_code) != model.code.as_ref() { + let existing = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::Code.eq(new_code.as_str())) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("部门编码已存在".to_string())); + } + } + } + + let mut active: department::ActiveModel = model.into(); + + if let Some(n) = name { + active.name = Set(n.clone()); + } + if let Some(c) = code { + active.code = Set(Some(c.clone())); + } + if let Some(mgr_id) = manager_id { + active.manager_id = Set(Some(*mgr_id)); + } + if let Some(so) = sort_order { + active.sort_order = Set(*so); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + + let updated = active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(DepartmentResp { + id: updated.id, + org_id: updated.org_id, + name: updated.name.clone(), + code: updated.code.clone(), + parent_id: updated.parent_id, + manager_id: updated.manager_id, + path: updated.path.clone(), + sort_order: updated.sort_order, + children: vec![], + }) + } + + /// Soft-delete a department by setting the `deleted_at` timestamp. + /// + /// Will not delete if child departments exist. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult<()> { + let model = department::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?; + + // Check for child departments + let children = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::ParentId.eq(id)) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if children.is_some() { + return Err(AuthError::Validation( + "该部门下存在子部门,无法删除".to_string(), + )); + } + + let mut active: department::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active + .update(db) + .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 }), + )); + Ok(()) + } +} + +/// Build a nested tree of `DepartmentResp` from a flat list of models. +fn build_dept_tree(items: &[department::Model]) -> Vec { + let mut children_map: HashMap, Vec<&department::Model>> = HashMap::new(); + for item in items { + children_map.entry(item.parent_id).or_default().push(item); + } + + fn build_node( + item: &department::Model, + map: &HashMap, Vec<&department::Model>>, + ) -> DepartmentResp { + let children = map + .get(&Some(item.id)) + .map(|items| items.iter().map(|i| build_node(i, map)).collect()) + .unwrap_or_default(); + DepartmentResp { + id: item.id, + org_id: item.org_id, + name: item.name.clone(), + code: item.code.clone(), + parent_id: item.parent_id, + manager_id: item.manager_id, + path: item.path.clone(), + sort_order: item.sort_order, + children, + } + } + + children_map + .get(&None) + .map(|root_items| { + root_items + .iter() + .map(|item| build_node(item, &children_map)) + .collect() + }) + .unwrap_or_default() +} diff --git a/crates/erp-auth/src/service/mod.rs b/crates/erp-auth/src/service/mod.rs index 45373d1..b1ad67d 100644 --- a/crates/erp-auth/src/service/mod.rs +++ b/crates/erp-auth/src/service/mod.rs @@ -1,6 +1,9 @@ pub mod auth_service; +pub mod dept_service; +pub mod org_service; pub mod password; pub mod permission_service; +pub mod position_service; pub mod role_service; pub mod seed; pub mod token_service; diff --git a/crates/erp-auth/src/service/org_service.rs b/crates/erp-auth/src/service/org_service.rs new file mode 100644 index 0000000..d64a734 --- /dev/null +++ b/crates/erp-auth/src/service/org_service.rs @@ -0,0 +1,273 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq}; +use crate::entity::organization; +use crate::error::{AuthError, AuthResult}; +use erp_core::events::EventBus; + +/// Organization CRUD service -- create, read, update, soft-delete organizations +/// within a tenant, supporting tree-structured hierarchy with path and level. +pub struct OrgService; + +impl OrgService { + /// Fetch all organizations for a tenant as a flat list (not deleted). + pub async fn list_flat( + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let items = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(items) + } + + /// Fetch all organizations for a tenant as a nested tree. + /// + /// Root nodes have `parent_id = None`. Children are grouped by `parent_id`. + pub async fn get_tree( + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let items = Self::list_flat(tenant_id, db).await?; + Ok(build_org_tree(&items)) + } + + /// Create a new organization within the current tenant. + /// + /// If `parent_id` is provided, computes `path` from the parent's path and id, + /// and sets `level = parent.level + 1`. Otherwise, level defaults to 1. + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + req: &CreateOrganizationReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult { + // Check code uniqueness within tenant if code is provided + if let Some(ref code) = req.code { + let existing = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::Code.eq(code.as_str())) + .filter(organization::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("组织编码已存在".to_string())); + } + } + + let (path, level) = if let Some(parent_id) = req.parent_id { + let parent = organization::Entity::find_by_id(parent_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("父级组织不存在".to_string()))?; + + let parent_path = parent.path.clone().unwrap_or_default(); + let computed_path = format!("{}{}/", parent_path, parent.id); + (Some(computed_path), parent.level + 1) + } else { + (None, 1) + }; + + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = organization::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(req.name.clone()), + code: Set(req.code.clone()), + parent_id: Set(req.parent_id), + path: Set(path), + level: Set(level), + sort_order: Set(req.sort_order.unwrap_or(0)), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .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 }), + )); + + Ok(OrganizationResp { + id, + name: req.name.clone(), + code: req.code.clone(), + parent_id: req.parent_id, + path: None, + level, + sort_order: req.sort_order.unwrap_or(0), + children: vec![], + }) + } + + /// Update editable organization fields (name, code, sort_order). + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &UpdateOrganizationReq, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = organization::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?; + + // If code is being changed, check uniqueness + if let Some(ref new_code) = req.code { + if Some(new_code) != model.code.as_ref() { + let existing = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::Code.eq(new_code.as_str())) + .filter(organization::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("组织编码已存在".to_string())); + } + } + } + + let mut active: organization::ActiveModel = model.into(); + + if let Some(ref name) = req.name { + active.name = Set(name.clone()); + } + if let Some(ref code) = req.code { + active.code = Set(Some(code.clone())); + } + if let Some(sort_order) = req.sort_order { + active.sort_order = Set(sort_order); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + + let updated = active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(OrganizationResp { + id: updated.id, + name: updated.name.clone(), + code: updated.code.clone(), + parent_id: updated.parent_id, + path: updated.path.clone(), + level: updated.level, + sort_order: updated.sort_order, + children: vec![], + }) + } + + /// Soft-delete an organization by setting the `deleted_at` timestamp. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult<()> { + let model = organization::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?; + + // Check for child organizations + let children = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::ParentId.eq(id)) + .filter(organization::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if children.is_some() { + return Err(AuthError::Validation( + "该组织下存在子组织,无法删除".to_string(), + )); + } + + let mut active: organization::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active + .update(db) + .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 }), + )); + Ok(()) + } +} + +/// Build a nested tree of `OrganizationResp` from a flat list of models. +/// +/// Root nodes (parent_id = None) form the top level. Each node recursively +/// includes its children grouped by parent_id. +fn build_org_tree(items: &[organization::Model]) -> Vec { + let mut children_map: HashMap, Vec<&organization::Model>> = HashMap::new(); + for item in items { + children_map.entry(item.parent_id).or_default().push(item); + } + + fn build_node( + item: &organization::Model, + map: &HashMap, Vec<&organization::Model>>, + ) -> OrganizationResp { + let children = map + .get(&Some(item.id)) + .map(|items| items.iter().map(|i| build_node(i, map)).collect()) + .unwrap_or_default(); + OrganizationResp { + id: item.id, + name: item.name.clone(), + code: item.code.clone(), + parent_id: item.parent_id, + path: item.path.clone(), + level: item.level, + sort_order: item.sort_order, + children, + } + } + + children_map + .get(&None) + .map(|root_items| { + root_items + .iter() + .map(|item| build_node(item, &children_map)) + .collect() + }) + .unwrap_or_default() +} diff --git a/crates/erp-auth/src/service/position_service.rs b/crates/erp-auth/src/service/position_service.rs new file mode 100644 index 0000000..2e9e4d7 --- /dev/null +++ b/crates/erp-auth/src/service/position_service.rs @@ -0,0 +1,218 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{CreatePositionReq, PositionResp}; +use crate::entity::department; +use crate::entity::position; +use crate::error::{AuthError, AuthResult}; +use erp_core::events::EventBus; + +/// Position CRUD service -- create, read, update, soft-delete positions +/// within a department. +pub struct PositionService; + +impl PositionService { + /// List all positions for a department within the given tenant. + pub async fn list( + dept_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + // Verify the department exists + let _dept = department::Entity::find_by_id(dept_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?; + + let items = position::Entity::find() + .filter(position::Column::TenantId.eq(tenant_id)) + .filter(position::Column::DeptId.eq(dept_id)) + .filter(position::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(items + .iter() + .map(|p| PositionResp { + id: p.id, + dept_id: p.dept_id, + name: p.name.clone(), + code: p.code.clone(), + level: p.level, + sort_order: p.sort_order, + }) + .collect()) + } + + /// Create a new position under the specified department. + pub async fn create( + dept_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &CreatePositionReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult { + // Verify the department exists + let _dept = department::Entity::find_by_id(dept_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?; + + // Check code uniqueness within tenant if code is provided + if let Some(ref code) = req.code { + let existing = position::Entity::find() + .filter(position::Column::TenantId.eq(tenant_id)) + .filter(position::Column::Code.eq(code.as_str())) + .filter(position::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("岗位编码已存在".to_string())); + } + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = position::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + dept_id: Set(dept_id), + name: Set(req.name.clone()), + code: Set(req.code.clone()), + level: Set(req.level.unwrap_or(1)), + sort_order: Set(req.sort_order.unwrap_or(0)), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .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 }), + )); + + Ok(PositionResp { + id, + dept_id, + name: req.name.clone(), + code: req.code.clone(), + level: req.level.unwrap_or(1), + sort_order: req.sort_order.unwrap_or(0), + }) + } + + /// Update editable position fields (name, code, level, sort_order). + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + name: &Option, + code: &Option, + level: &Option, + sort_order: &Option, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = position::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?; + + // If code is being changed, check uniqueness + if let Some(new_code) = code { + if Some(new_code) != model.code.as_ref() { + let existing = position::Entity::find() + .filter(position::Column::TenantId.eq(tenant_id)) + .filter(position::Column::Code.eq(new_code.as_str())) + .filter(position::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("岗位编码已存在".to_string())); + } + } + } + + let mut active: position::ActiveModel = model.into(); + + if let Some(n) = name { + active.name = Set(n.clone()); + } + if let Some(c) = code { + active.code = Set(Some(c.clone())); + } + if let Some(l) = level { + active.level = Set(*l); + } + if let Some(so) = sort_order { + active.sort_order = Set(*so); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + + let updated = active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(PositionResp { + id: updated.id, + dept_id: updated.dept_id, + name: updated.name.clone(), + code: updated.code.clone(), + level: updated.level, + sort_order: updated.sort_order, + }) + } + + /// Soft-delete a position by setting the `deleted_at` timestamp. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult<()> { + let model = position::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?; + + let mut active: position::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active + .update(db) + .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 }), + )); + Ok(()) + } +}