feat: complete Phase 1 infrastructure

- erp-core: error types, shared types, event bus, ErpModule trait
- erp-server: config loading, database/Redis connections, migrations
- erp-server/migration: tenants table with SeaORM
- apps/web: Vite + React 18 + TypeScript + Ant Design 5 + TailwindCSS
- Web frontend: main layout with sidebar, header, routing
- Docker: PostgreSQL 16 + Redis 7 development environment
- All workspace crates compile successfully (cargo check passes)
This commit is contained in:
iven
2026-04-11 01:07:31 +08:00
parent eb856b1d73
commit 5901ee82f0
36 changed files with 4542 additions and 221 deletions

View File

@@ -1,3 +1,5 @@
@wiki/index.md
# ERP 平台底座 — 协作与实现规则
> **ERP Platform Base** 是一个模块化的商业 SaaS ERP 底座,目标是提供核心基础设施(身份权限、工作流、消息、配置),使行业业务模块(进销存、生产、财务等)可以快速插接。

475
Cargo.lock generated
View File

@@ -61,21 +61,62 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arc-swap"
version = "1.9.1"
@@ -163,42 +204,13 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core 0.4.5",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"itoa",
"matchit 0.7.3",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"sync_wrapper",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core 0.5.6",
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
@@ -208,7 +220,7 @@ dependencies = [
"hyper",
"hyper-util",
"itoa",
"matchit 0.8.4",
"matchit",
"memchr",
"mime",
"percent-encoding",
@@ -225,26 +237,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum-core"
version = "0.5.6"
@@ -435,6 +427,52 @@ dependencies = [
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
@@ -599,6 +637,40 @@ dependencies = [
"typenum",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.117",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -620,17 +692,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -720,7 +781,7 @@ name = "erp-auth"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.8",
"axum",
"chrono",
"erp-core",
"sea-orm",
@@ -747,7 +808,7 @@ name = "erp-config"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.8",
"axum",
"chrono",
"erp-core",
"sea-orm",
@@ -763,7 +824,8 @@ name = "erp-core"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.8",
"async-trait",
"axum",
"chrono",
"sea-orm",
"serde",
@@ -779,7 +841,7 @@ name = "erp-message"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.8",
"axum",
"chrono",
"erp-core",
"sea-orm",
@@ -794,10 +856,12 @@ dependencies = [
name = "erp-server"
version = "0.1.0"
dependencies = [
"axum 0.8.8",
"anyhow",
"axum",
"config",
"erp-common",
"erp-core",
"erp-server-migration",
"redis",
"sea-orm",
"serde",
@@ -808,7 +872,14 @@ dependencies = [
"tracing",
"tracing-subscriber",
"utoipa",
"utoipa-swagger-ui",
]
[[package]]
name = "erp-server-migration"
version = "0.1.0"
dependencies = [
"sea-orm-migration",
"tokio",
]
[[package]]
@@ -816,7 +887,7 @@ name = "erp-workflow"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.8",
"axum",
"chrono",
"erp-core",
"sea-orm",
@@ -892,6 +963,12 @@ dependencies = [
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1046,6 +1123,12 @@ dependencies = [
"wasip3",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1337,6 +1420,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@@ -1381,6 +1470,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
@@ -1507,12 +1602,6 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "matchit"
version = "0.8.4"
@@ -1550,16 +1639,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -1687,6 +1766,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "ordered-float"
version = "4.6.0"
@@ -2186,40 +2271,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.117",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rust-ini"
version = "0.20.0"
@@ -2302,15 +2353,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2361,6 +2403,25 @@ dependencies = [
"uuid",
]
[[package]]
name = "sea-orm-cli"
version = "1.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da80ebcdb44571e86f03a2bdcb5532136a87397f366f38bbce64673fc5e6a450"
dependencies = [
"chrono",
"clap",
"dotenvy",
"glob",
"regex",
"sea-schema",
"sqlx",
"tokio",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
name = "sea-orm-macros"
version = "1.1.20"
@@ -2375,6 +2436,22 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sea-orm-migration"
version = "1.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c577f2959277e936c1d08109acd1e08fc36a95ef29ec028190ba82cad8f96e"
dependencies = [
"async-trait",
"clap",
"dotenvy",
"sea-orm",
"sea-orm-cli",
"sea-schema",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "sea-query"
version = "0.32.7"
@@ -2386,6 +2463,7 @@ dependencies = [
"inherent",
"ordered-float",
"rust_decimal",
"sea-query-derive",
"serde_json",
"time",
"uuid",
@@ -2407,6 +2485,45 @@ dependencies = [
"uuid",
]
[[package]]
name = "sea-query-derive"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
dependencies = [
"darling",
"heck 0.4.1",
"proc-macro2",
"quote",
"syn 2.0.117",
"thiserror",
]
[[package]]
name = "sea-schema"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2239ff574c04858ca77485f112afea1a15e53135d3097d0c86509cef1def1338"
dependencies = [
"futures",
"sea-query",
"sea-query-binder",
"sea-schema-derive",
"sqlx",
]
[[package]]
name = "sea-schema-derive"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "seahash"
version = "4.1.0"
@@ -2855,6 +2972,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
@@ -3264,12 +3387,6 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -3333,6 +3450,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.4.0"
@@ -3358,24 +3481,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "utoipa-swagger-ui"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db4b5ac679cc6dfc5ea3f2823b0291c777750ffd5e13b21137e0f7ac0e8f9617"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"url",
"utoipa",
"zip",
]
[[package]]
name = "uuid"
version = "1.23.0"
@@ -3406,16 +3511,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -3570,15 +3665,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@@ -4033,37 +4119,8 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View File

@@ -8,6 +8,7 @@ members = [
"crates/erp-workflow",
"crates/erp-message",
"crates/erp-config",
"crates/erp-server/migration",
]
[workspace.package]
@@ -60,7 +61,8 @@ argon2 = "0.5"
# API docs
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
utoipa-swagger-ui = { version = "8", features = ["axum"] }
# utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用
# utoipa-swagger-ui = { version = "8", features = ["axum"] }
# Validation
validator = { version = "0.19", features = ["derive"] }

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# ERP Platform Base
模块化商业 SaaS ERP 平台底座。
## 技术栈
| 层级 | 技术 |
|------|------|
| 后端 | Rust (Axum 0.8 + SeaORM + Tokio) |
| 数据库 | PostgreSQL 16+ |
| 缓存 | Redis 7+ |
| 前端 | Vite + React 18 + TypeScript + Ant Design 5 |
## 项目结构
```
erp/
├── crates/
│ ├── erp-core/ # 基础类型、错误、事件总线、模块 trait
│ ├── erp-common/ # 共享工具
│ ├── erp-auth/ # 身份与权限 (Phase 2)
│ ├── erp-workflow/ # 工作流引擎 (Phase 4)
│ ├── erp-message/ # 消息中心 (Phase 5)
│ ├── erp-config/ # 系统配置 (Phase 3)
│ └── erp-server/ # Axum 服务入口
│ └── migration/ # SeaORM 数据库迁移
├── apps/web/ # React SPA 前端
├── docker/ # Docker 开发环境
└── docs/ # 文档
```
## 快速开始
### 1. 启动基础设施
```bash
cd docker && docker compose up -d
```
### 2. 启动后端
```bash
cargo run -p erp-server
```
### 3. 启动前端
```bash
cd apps/web && pnpm install && pnpm dev
```
### 4. 访问
- 前端: http://localhost:5173
- 后端 API: http://localhost:3000
## 开发命令
```bash
cargo check # 编译检查
cargo test --workspace # 运行测试
cargo run -p erp-server # 启动后端
cd apps/web && pnpm dev # 启动前端
```

24
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
apps/web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
apps/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

37
apps/web/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"antd": "^6.3.5",
"axios": "^1.15.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

3177
apps/web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
apps/web/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

30
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { HashRouter, Routes, Route } from 'react-router-dom';
import { ConfigProvider, theme as antdTheme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import MainLayout from './layouts/MainLayout';
import { useAppStore } from './stores/app';
function HomePage() {
return <div> ERP </div>;
}
export default function App() {
const { theme: appTheme } = useAppStore();
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: appTheme === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
}}
>
<HashRouter>
<MainLayout>
<Routes>
<Route path="/" element={<HomePage />} />
</Routes>
</MainLayout>
</HashRouter>
</ConfigProvider>
);
}

5
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,5 @@
@import "tailwindcss";
body {
margin: 0;
}

View File

@@ -0,0 +1,87 @@
import { Layout, Menu, theme, Button, Avatar, Badge, Space } from 'antd';
import {
HomeOutlined,
UserOutlined,
SafetyOutlined,
BellOutlined,
SettingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons';
import { useAppStore } from '../stores/app';
const { Header, Sider, Content, Footer } = Layout;
const menuItems = [
{ key: '/', icon: <HomeOutlined />, label: '首页' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/roles', icon: <SafetyOutlined />, label: '权限管理' },
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
];
export default function MainLayout({ children }: { children: React.ReactNode }) {
const { sidebarCollapsed, toggleSidebar, tenantName } = useAppStore();
const { token } = theme.useToken();
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={sidebarCollapsed} width={220}>
<div
style={{
height: 48,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: sidebarCollapsed ? 16 : 18,
fontWeight: 'bold',
}}
>
{sidebarCollapsed ? 'E' : 'ERP Platform'}
</div>
<Menu theme="dark" mode="inline" items={menuItems} defaultSelectedKeys={['/']} />
</Sider>
<Layout>
<Header
style={{
padding: '0 16px',
background: token.colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Space>
<Button
type="text"
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={toggleSidebar}
/>
</Space>
<Space size="middle">
<Badge count={5}>
<BellOutlined style={{ fontSize: 18 }} />
</Badge>
<Avatar icon={<UserOutlined />} />
<span>Admin</span>
</Space>
</Header>
<Content
style={{
margin: 16,
padding: 24,
background: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
minHeight: 280,
}}
>
{children}
</Content>
<Footer style={{ textAlign: 'center', padding: '8px 16px' }}>
{tenantName || 'ERP Platform'} · v0.1.0
</Footer>
</Layout>
</Layout>
);
}

10
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,23 @@
import { create } from 'zustand';
interface AppState {
isLoggedIn: boolean;
tenantName: string;
theme: 'light' | 'dark';
sidebarCollapsed: boolean;
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void;
login: () => void;
logout: () => void;
}
export const useAppStore = create<AppState>((set) => ({
isLoggedIn: false,
tenantName: '',
theme: 'light',
sidebarCollapsed: false,
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setTheme: (theme) => set({ theme }),
login: () => set({ isLoggedIn: true }),
logout: () => set({ isLoggedIn: false }),
}));

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

20
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:3000",
ws: true,
},
},
},
});

View File

@@ -1 +1,3 @@
/// Shared utility functions for the ERP platform.
#[allow(dead_code)]
pub fn noop() {}

View File

@@ -14,3 +14,4 @@ anyhow.workspace = true
tracing.workspace = true
axum.workspace = true
sea-orm.workspace = true
async-trait = "0.1"

View File

@@ -6,6 +6,7 @@ use crate::events::EventBus;
/// 模块注册接口
/// 所有业务模块Auth, Workflow, Message, Config, 行业模块)都实现此 trait
#[async_trait::async_trait]
pub trait ErpModule: Send + Sync {
/// 模块名称(唯一标识)
fn name(&self) -> &str;
@@ -27,19 +28,13 @@ pub trait ErpModule: Send + Sync {
fn register_event_handlers(&self, _bus: &EventBus) {}
/// 租户创建时的初始化钩子
fn on_tenant_created(
&self,
_tenant_id: Uuid,
) -> impl std::future::Future<Output = AppResult<()>> + Send {
async { Ok(()) }
async fn on_tenant_created(&self, _tenant_id: Uuid) -> AppResult<()> {
Ok(())
}
/// 租户删除时的清理钩子
fn on_tenant_deleted(
&self,
_tenant_id: Uuid,
) -> impl std::future::Future<Output = AppResult<()>> + Send {
async { Ok(()) }
async fn on_tenant_deleted(&self, _tenant_id: Uuid) -> AppResult<()> {
Ok(())
}
}

View File

@@ -20,6 +20,7 @@ config.workspace = true
sea-orm.workspace = true
redis.workspace = true
utoipa.workspace = true
utoipa-swagger-ui.workspace = true
serde_json.workspace = true
serde.workspace = true
erp-server-migration = { path = "migration" }
anyhow.workspace = true

View File

@@ -0,0 +1,8 @@
[package]
name = "erp-server-migration"
version = "0.1.0"
edition = "2024"
[dependencies]
sea-orm-migration.workspace = true
tokio.workspace = true

View File

@@ -0,0 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20260410_000001_create_tenant;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20260410_000001_create_tenant::Migration)]
}
}

View File

@@ -0,0 +1,74 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Tenant::Table)
.if_not_exists()
.col(
ColumnDef::new(Tenant::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Tenant::Name).string().not_null())
.col(
ColumnDef::new(Tenant::Code)
.string()
.not_null()
.unique_key(),
)
.col(
ColumnDef::new(Tenant::Status)
.string()
.not_null()
.default("active"),
)
.col(ColumnDef::new(Tenant::Settings).json().null())
.col(
ColumnDef::new(Tenant::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Tenant::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Tenant::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Tenant::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Tenant {
Table,
Id,
Name,
Code,
Status,
Settings,
CreatedAt,
UpdatedAt,
DeletedAt,
}

View File

@@ -3,6 +3,8 @@ mod db;
use axum::Router;
use config::AppConfig;
use erp_server_migration::Migrator;
use erp_server_migration::MigratorTrait;
use tracing_subscriber::EnvFilter;
#[tokio::main]
@@ -24,6 +26,10 @@ async fn main() -> anyhow::Result<()> {
// Connect to database
let db = db::connect(&config.database).await?;
// Run migrations
Migrator::up(&db, None).await?;
tracing::info!("Database migrations applied");
// Connect to Redis
let _redis_client = redis::Client::open(&config.redis.url[..])?;
tracing::info!("Redis client created");

84
wiki/architecture.md Normal file
View File

@@ -0,0 +1,84 @@
# architecture (架构决策记录)
## 设计思想
ERP 平台采用 **模块化单体 + 渐进式拆分** 架构。核心原则:模块间零直接依赖,所有跨模块通信通过事件总线和 trait 接口。
## 关键架构决策
### Q: 为什么用模块化单体而非微服务?
**A:** ERP 系统的模块间数据一致性要求高,分布式事务成本大。单体起步,模块边界清晰,未来需要时可按模块拆分为微服务。`ErpModule` trait 天然支持这种渐进式迁移。
### Q: 为什么用 UUIDv7 而不是自增 ID
**A:** UUIDv7 是时间排序的,既有 UUID 的分布式唯一性,又有接近自增 ID 的索引性能。对多租户 SaaS 尤其重要——不同租户的数据不会因为 ID 冲突而互相影响。
### Q: 为什么用 broadcast channel 做事件总线?
**A:** `tokio::sync::broadcast` 提供多消费者发布/订阅,语义匹配模块间事件通知。设计规格要求 outbox 模式(持久化到 domain_events 表),但当前 Phase 1 先用内存 broadcast后续再补持久化。
### Q: 为什么错误类型跨 crate 边界必须用 thiserror
**A:** `anyhow` 的错误没有类型信息,无法在 API 层做精确的 HTTP 状态码映射。`thiserror` 定义明确的错误变体,`AppError` 可以精确映射到 400/401/403/404/409/500。crate 内部可以用 `anyhow` 简化,但对外必须转 `AppError`
### Q: 为什么 tenant_id 不在 API 路径中?
**A:** 从 JWT token 中提取 tenant_id通过中间件注入 `TenantContext`。这防止了:
- 用户手动修改 URL 访问其他租户数据
- API 路径暴露租户信息
- 开发者忘记检查租户权限
管理员接口例外,可以通过路径指定 tenant_id。
### Q: 为什么前端用 HashRouter 而非 BrowserRouter
**A:** 部署时可能不在根路径下HashRouter 不需要服务端配置 fallback 路由。对 SPA 来说更稳健。
## 模块依赖铁律
```
erp-core (L1)
erp-common (L1)
|
+--------------+--------------+--------------+
| | | |
erp-auth erp-config erp-workflow erp-message (L2)
| | | |
+--------------+--------------+--------------+
|
erp-server (L3: 唯一组装点)
```
**禁止:**
- L2 模块之间直接依赖
- L1 模块依赖任何业务模块
- 绕过事件总线直接调用其他模块
## 多租户隔离策略
**当前策略:共享数据库 + tenant_id 列过滤**
所有业务表包含 `tenant_id` 列,查询时通过中间件自动注入过滤条件。这是最简单的 SaaS 多租户方案,未来可扩展为:
- Schema 隔离 — 每个租户独立 schema
- 数据库隔离 — 每个租户独立数据库(私有化部署)
`ErpModule::on_tenant_created()``on_tenant_deleted()` 钩子确保模块能在租户创建/删除时初始化/清理数据。
## 技术选型理由
| 选择 | 理由 |
|------|------|
| Axum 0.8 | Tokio 团队维护,与 tower 生态无缝集成,类型安全路由 |
| SeaORM 1.1 | 异步、类型安全、Rust 原生 ORM迁移工具完善 |
| PostgreSQL 16 | 企业级关系型数据库JSON 支持好,扩展丰富 |
| Redis 7 | 高性能缓存,会话存储,限流 token bucket |
| React 19 + Ant Design 6 | 成熟的组件库,企业后台 UI 标配 |
| Zustand | 极简状态管理,无 boilerplate |
| utoipa | Rust 代码生成 OpenAPI 文档,零额外维护 |
## 关联模块
- **[[erp-core]]** — 架构契约的定义者
- **[[erp-server]]** — 架构的组装执行者
- **[[database]]** — 多租户隔离的物理实现

73
wiki/database.md Normal file
View File

@@ -0,0 +1,73 @@
# database (数据库迁移与模式)
## 设计思想
数据库迁移使用 SeaORM Migration 框架,遵循以下原则:
- **所有表必须包含标准字段** — `id`(UUIDv7), `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
- **软删除** — 不执行硬删除,设置 `deleted_at` 时间戳
- **乐观锁** — 更新时检查 `version` 字段
- **多租户隔离** — 所有业务表必须含 `tenant_id`,查询时自动过滤
- **幂等迁移** — 使用 `if_not_exists` 确保可重复执行
- **可回滚** — 每个迁移必须实现 `down()` 方法
## 代码逻辑
### 迁移文件命名规则
```
m{YYYYMMDD}_{6位序号}_{描述}.rs
例: m20260410_000001_create_tenant.rs
```
### 当前表结构
**tenant 表** (唯一已实现的表):
| 列名 | 类型 | 约束 |
|------|------|------|
| id | UUID | PK, NOT NULL |
| name | STRING | NOT NULL |
| code | STRING | NOT NULL, UNIQUE |
| status | STRING | NOT NULL, DEFAULT 'active' |
| settings | JSON | NULLABLE |
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
| deleted_at | TIMESTAMPTZ | NULLABLE |
### 已知缺失字段
tenant 表缺少 `BaseFields` 要求的:
- `created_by` — 创建人
- `updated_by` — 最后修改人
- `version` — 乐观锁版本号
### 迁移执行
```
erp-server 启动 → Migrator::up(&db_conn) → 自动运行所有 pending 迁移
```
## 关联模块
- **[[erp-core]]** — `BaseFields` 定义了标准字段规范,迁移表结构必须对齐
- **[[erp-server]]** — 启动时自动运行迁移
- **[[erp-auth]]** — Phase 2 将创建 users, roles, permissions 表
- **[[erp-config]]** — Phase 3 将创建 system_configs 表
- **[[erp-workflow]]** — Phase 4 将创建 workflow_definitions, workflow_instances 表
- **[[erp-message]]** — Phase 5 将创建 messages, notification_settings 表
## 关键文件
| 文件 | 职责 |
|------|------|
| `crates/erp-server/migration/src/lib.rs` | Migrator 注册所有迁移 |
| `crates/erp-server/migration/src/m20260410_000001_create_tenant.rs` | tenant 表迁移 |
| `crates/erp-core/src/types.rs` | BaseFields 标准字段定义 |
| `docker/docker-compose.yml` | PostgreSQL 16 服务定义 |
## 未来迁移计划 (按 Phase)
| Phase | 表 | 说明 |
|-------|-----|------|
| Phase 2 | users, roles, permissions, user_roles, role_permissions | RBAC + ABAC |
| Phase 3 | system_configs, config_histories | 层级配置 |
| Phase 4 | workflow_definitions, workflow_instances, workflow_tasks | BPMN 工作流 |
| Phase 5 | messages, notification_settings, message_templates | 多渠道消息 |
| 持续 | domain_events | 事件 outbox 表 |

68
wiki/erp-core.md Normal file
View File

@@ -0,0 +1,68 @@
# erp-core
## 设计思想
`erp-core` 是整个 ERP 平台的 L1 基础层,所有业务模块的唯一共同依赖。它的职责是定义**跨模块共享的契约**,而非实现业务逻辑。
核心设计决策:
- **AppError 统一错误体系** — 6 种错误变体映射到 HTTP 状态码,业务 crate 只需 `?` 传播错误,由 Axum `IntoResponse` 自动转换
- **EventBus 进程内广播** — 用 `tokio::sync::broadcast` 实现发布/订阅,模块间零耦合通信
- **ErpModule 插件 trait** — 每个业务模块实现此 trait`ModuleRegistry` 统一注册路由和事件处理器
- **BaseFields 强制多租户** — 所有实体的基础字段模板,确保 `tenant_id` 从第一天就存在
## 代码逻辑
### 错误处理链
```
业务 crate (thiserror) → AppError → IntoResponse → HTTP JSON 响应
数据库 (sea_orm::DbErr) → From 转换 → AppError (自动识别 duplicate key → Conflict)
```
错误响应统一格式:`{ "error": "not_found", "message": "资源不存在", "details": null }`
### 事件总线
```
发布者: EventBus::publish(DomainEvent) → broadcast channel
订阅者: EventBus::subscribe() → Receiver<DomainEvent>
事件字段: id(UUIDv7), event_type("user.created"), tenant_id, payload(JSON), timestamp, correlation_id
```
事件类型命名规则:`{模块}.{动作}``user.created`, `workflow.task.completed`
### 模块注册
```
业务模块实现 ErpModule trait → ModuleRegistry::register() →
build_router(): 折叠所有模块的 register_routes() → Axum Router
register_handlers(): 注册所有模块的事件处理器 → EventBus
```
### 共享类型
- `TenantContext` — 中间件注入的租户上下文tenant_id, user_id, roles, permissions
- `Pagination` / `PaginatedResponse<T>` — 分页查询标准化(每页上限 100
- `ApiResponse<T>` — API 统一信封 `{ success, data, message }`
## 关联模块
- **[[erp-server]]** — 消费所有 erp-core 类型和 trait是唯一组装点
- **[[erp-auth]]** — 实现 `ErpModule` trait发布认证事件
- **[[erp-workflow]]** — 实现 `ErpModule` trait订阅业务事件
- **[[erp-message]]** — 实现 `ErpModule` trait订阅通知事件
- **[[erp-config]]** — 实现 `ErpModule` trait
- **[[database]]** — 迁移表结构必须与 `BaseFields` 对齐
## 关键文件
| 文件 | 职责 |
|------|------|
| `crates/erp-core/src/error.rs` | AppError 定义、HTTP 映射、From 转换 |
| `crates/erp-core/src/events.rs` | DomainEvent、EventBus、EventHandler trait |
| `crates/erp-core/src/module.rs` | ErpModule trait、ModuleRegistry |
| `crates/erp-core/src/types.rs` | BaseFields、Pagination、ApiResponse、TenantContext |
| `crates/erp-core/src/lib.rs` | 模块导出入口 |
## 当前状态
**已实现Phase 1 可用。** 但以下部分尚未被 erp-server 集成:
- `ModuleRegistry` 未在 `main.rs` 中使用
- `EventBus` 未创建实例
- `TenantContext` 未通过中间件注入

66
wiki/erp-server.md Normal file
View File

@@ -0,0 +1,66 @@
# erp-server
## 设计思想
`erp-server` 是 L3 层——**唯一的组装点**。它不包含业务逻辑,只负责把所有业务模块组装成可运行的服务。
核心决策:
- **配置优先** — 使用 `config` crate 从 TOML 文件 + 环境变量加载,`ERP__` 前缀覆盖(如 `ERP__DATABASE__URL`
- **启动序列严格有序** — 配置 → 日志 → 数据库 → 迁移 → Redis → 路由 → 监听,每步失败即终止
- **单一入口** — 所有模块通过 `ModuleRegistry` 注册server 本身不直接 import 业务模块的类型
## 代码逻辑
### 启动流程 (`main.rs`)
```
1. AppConfig::load() ← config/default.toml + 环境变量
2. init_tracing(level) ← JSON 格式日志
3. db::connect(&db_config) ← SeaORM 连接池 (max=20, min=5)
4. Migrator::up(&db_conn) ← 运行所有待执行迁移
5. redis::Client::open(url) ← Redis 客户端(当前未使用)
6. Router::new() ← 当前仅有 404 fallback
7. bind(host, port).serve() ← 启动 HTTP 服务
```
### 配置结构
```
AppConfig
├── server: ServerConfig { host: "0.0.0.0", port: 3000 }
├── database: DatabaseConfig { url, max_connections: 20, min_connections: 5 }
├── redis: RedisConfig { url: "redis://localhost:6379" }
├── jwt: JwtConfig { secret, access_token_ttl, refresh_token_ttl }
└── log: LogConfig { level: "info" }
```
### 当前状态
- 数据库连接池正常工作
- 迁移自动执行
- **没有注册任何路由** — 仅返回 404
- **没有使用 ModuleRegistry** — 未集成业务模块
- Redis 客户端已创建但未执行任何命令
- 缺少 CORS、压缩、请求追踪中间件
## 关联模块
- **[[erp-core]]** — 提供 AppError、ErpModule trait、ModuleRegistry
- **[[database]]** — 迁移文件通过 `erp-server-migration` crate 引用
- **[[infrastructure]]** — Docker 提供 PostgreSQL 和 Redis 服务
- **[[frontend]]** — Vite 代理 `/api` 请求到 server 的 3000 端口
## 关键文件
| 文件 | 职责 |
|------|------|
| `crates/erp-server/src/main.rs` | 服务启动入口 |
| `crates/erp-server/src/config.rs` | 5 个配置 struct + 加载逻辑 |
| `crates/erp-server/src/db.rs` | SeaORM 连接池配置 |
| `crates/erp-server/config/default.toml` | 默认配置值 |
| `crates/erp-server/Cargo.toml` | 依赖声明 |
## 待完成 (Phase 1 剩余)
1. 实例化 `ModuleRegistry` 并注册模块
2. 添加 CORS 中间件tower-http
3. 添加请求追踪中间件
4. 将 Redis 连接注入 AppState
5. 健康检查端点 (`/api/v1/health`)

81
wiki/frontend.md Normal file
View File

@@ -0,0 +1,81 @@
# frontend (Web 前端)
## 设计思想
前端是一个 Vite + React SPA遵循 **UI 层只做展示** 的原则:
- **组件库优先** — 使用 Ant Design不自造轮子
- **状态集中** — Zustand 管理全局状态(主题、侧边栏、认证)
- **API 层分离** — HTTP 调用封装到 service 层,组件不直接 fetch
- **代理开发** — Vite 开发服务器代理 `/api` 到后端 3000 端口
版本实际使用情况(与设计规格有差异):
| 技术 | 规格 | 实际 |
|------|------|------|
| React | 18 | 19.2.4 |
| Ant Design | 5 | 6.3.5 |
| React Router | 7 | 7.14.0 |
## 代码逻辑
### 应用结构
```
main.tsx → App.tsx (ConfigProvider + HashRouter) → MainLayout → 各页面组件
```
### MainLayout 布局
经典 SaaS 后台管理布局:
- **左侧 Sidebar** — 可折叠暗色菜单,分组:首页/用户/权限/设置
- **顶部 Header** — 侧边栏切换 + 通知徽标(硬编码5) + 头像("Admin")
- **中间 Content** — React Router Outlet多标签页切换
- **底部 Footer** — 租户名 + 版本号
### 状态管理 (Zustand)
```typescript
appStore {
isLoggedIn: boolean // 未使用,无登录页
tenantName: string // 默认 "ERP Platform"
theme: 'light' | 'dark' // 切换 Ant Design 主题
sidebarCollapsed: boolean
toggleSidebar(), setTheme(), login(), logout()
}
```
### 开发服务器代理
```
http://localhost:5173/api/* → http://localhost:3000/* (API)
ws://localhost:5173/ws/* → ws://localhost:3000/* (WebSocket)
```
### 当前状态
- 布局壳体完整,暗色/亮色主题切换可用
- 只有一个路由 `/` → 占位 HomePage ("Welcome to ERP Platform")
- 无 API 调用、无认证流程、无真实数据
- 通知计数硬编码为 5用户名硬编码为 "Admin"
- 未实现 i18n代码中有 zh_CN locale 但文案硬编码)
## 关联模块
- **[[erp-server]]** — API 后端,通过 Vite proxy 连接
- **[[infrastructure]]** — Docker 提供 PostgreSQL + Redis
## 关键文件
| 文件 | 职责 |
|------|------|
| `apps/web/src/main.tsx` | React 入口 |
| `apps/web/src/App.tsx` | 根组件ConfigProvider + 路由 |
| `apps/web/src/layouts/MainLayout.tsx` | 完整后台管理布局 |
| `apps/web/src/stores/app.ts` | Zustand 全局状态 |
| `apps/web/src/index.css` | TailwindCSS 导入 |
| `apps/web/vite.config.ts` | Vite 配置 + API 代理 |
| `apps/web/package.json` | 依赖声明 |
## 待实现 (按 Phase)
| Phase | 内容 |
|-------|------|
| Phase 2 | 登录页、用户管理页、角色权限页 |
| Phase 3 | 系统配置管理页 |
| Phase 4 | 工作流设计器、审批列表 |
| Phase 5 | 消息中心、通知设置 |

65
wiki/index.md Normal file
View File

@@ -0,0 +1,65 @@
# ERP 平台底座 — 知识库
## 项目画像
**模块化 SaaS ERP 底座**Rust + React 技术栈,提供身份权限/工作流/消息/配置四大基础模块,支持行业业务模块快速插接。
关键数字:
- 8 个 Rust crate4 个 placeholder1 个前端 SPA
- 1 个数据库迁移tenant 表)
- Phase 1 基础设施完成约 85%
## 模块导航树
### L1 基础层
- [[erp-core]] — 错误体系 · 事件总线 · 模块 trait · 共享类型
- [[erp-common]] — 共享工具(当前为 stub
### L2 业务层(均为 placeholder
- erp-auth — 身份与权限Phase 2
- erp-config — 系统配置Phase 3
- erp-workflow — 工作流引擎Phase 4
- erp-message — 消息中心Phase 5
### L3 组装层
- [[erp-server]] — Axum 服务入口 · 配置加载 · 数据库连接 · 迁移执行
### 基础设施
- [[database]] — SeaORM 迁移 · 多租户表结构 · 软删除模式
- [[infrastructure]] — Docker Compose · PostgreSQL 16 · Redis 7
- [[frontend]] — React SPA · Ant Design 布局 · Zustand 状态
### 横切关注点
- [[architecture]] — 架构决策记录 · 设计原则 · 技术选型理由
## 核心架构决策
**模块间如何通信?** 通过 [[erp-core]] 的 EventBus 发布/订阅 DomainEvent不直接依赖。
**多租户怎么隔离?** 共享数据库 + tenant_id 列过滤,中间件从 JWT 注入 TenantContext。详见 [[database]] 和 [[architecture]]。
**错误怎么传播?** 业务 crate 用 thiserror → AppError → Axum IntoResponse 自动转 HTTP。详见 [[erp-core]] 错误处理链。
**为什么没有路由?** Phase 1 只搭建基础设施。ModuleRegistry 已定义但未集成到 [[erp-server]]Phase 2 开始注册路由。
**版本差异怎么办?** package.json 使用 React 19 + Ant Design 6比规格文档更新以实际代码为准。
## 开发进度
| Phase | 内容 | 状态 |
|-------|------|------|
| 1 | 基础设施 | 85% — 缺 README、ModuleRegistry 集成、中间件 |
| 2 | 身份与权限 | 待开始 |
| 3 | 系统配置 | 待开始 |
| 4 | 工作流引擎 | 待开始 |
| 5 | 消息中心 | 待开始 |
| 6 | 整合与打磨 | 待开始 |
## 关键文档索引
| 文档 | 位置 |
|------|------|
| 设计规格 | `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` |
| 实施计划 | `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` |
| 协作规则 | `CLAUDE.md` |
| 设计评审 | `plans/squishy-pondering-aho-agent-a23c7497aadc6da41.md` |

57
wiki/infrastructure.md Normal file
View File

@@ -0,0 +1,57 @@
# infrastructure (Docker 与开发环境)
## 设计思想
开发环境使用 Docker Compose 提供基础设施服务,应用服务在宿主机运行。这种设计允许:
- 后端 Rust 服务快速重启(无需容器化构建)
- 前端 Vite 热更新直接在宿主机
- 数据库和缓存服务标准化,团队成员环境一致
## 代码逻辑
### 服务配置
| 服务 | 镜像 | 端口 | 用途 |
|------|------|------|------|
| erp-postgres | postgres:16-alpine | 5432 | 主数据库 |
| erp-redis | redis:7-alpine | 6379 | 缓存 + 会话 |
### 连接信息
```
PostgreSQL: postgres://erp:erp_dev_2024@localhost:5432/erp
Redis: redis://localhost:6379
```
### 健康检查
- PostgreSQL: `pg_isready` 每 5 秒5 次重试
- Redis: `redis-cli ping` 每 5 秒5 次重试
### 数据持久化
- `postgres_data` — 命名卷PostgreSQL 数据
- `redis_data` — 命名卷Redis 数据
### 环境变量
通过 `docker/.env.example` 文档化,使用默认值即可启动开发环境。
## 关联模块
- **[[erp-server]]** — 连接 PostgreSQL 和 Redis
- **[[database]]** — 迁移在 PostgreSQL 中执行
- **[[frontend]]** — Vite 代理 API 到后端
## 关键文件
| 文件 | 职责 |
|------|------|
| `docker/docker-compose.yml` | 服务定义 |
| `docker/.env.example` | 环境变量模板 |
| `crates/erp-server/config/default.toml` | 默认连接配置 |
## 常用命令
```bash
cd docker && docker compose up -d # 启动服务
docker compose -f docker/docker-compose.yml ps # 查看状态
docker compose -f docker/docker-compose.yml down # 停止
docker exec -it erp-postgres psql -U erp # 连接数据库
```