Модуль зохиох гарын авлага
Gerege Nexus-ийн модуль зохиох гарын авлагад тавтай морил! Энэхүү гарын авлага нь гадаад хөгжүүлэгчид платформд зориулж захиалгат бизнесийн апп модулийг хэрхэн бичих, бүртгэх, түгээх талаар тайлбарлана.
Модулийн архитектурын тойм
Gerege Nexus-д бизнесийн модулиуд нь
backend/internal/apps/ дор Go хэл дээр, компиляцийн үеийн
багц (package) хэлбэрээр бичигдэнэ. (Go модулийн зам нь
github.com/gerege-systems/open-gerege-mn-erp/backend хэвээр
— тэр нь брэнд биш, тодорхойлогч.)
Модуль бүр backend/internal/module.go-д
тодорхойлсон Module интерфэйсийг ЗААВАЛ хэрэгжүүлэх
ёстой:
type Module interface {
ID() string
Name() string
Version() string
Dependencies() []Dependency
Permissions() []PermissionDefinition
Menus() []MenuDefinition
RegisterRoutes(r chi.Router, tenantAuthMiddleware func(http.Handler) http.Handler)
}
Хурдан эхлэл: нэг командаар модуль үүсгэх
make new-app slug=helpdesk name="Helpdesk" name_mn="Тусламжийн төв"
# or: ./scripts/new-app.sh helpdesk "Helpdesk" "Тусламжийн төв"
Энэ команд нь backend модуль (тенантаар хязгаарласан CRUD-тай бүрэн
Module хэрэгжүүлэлт), goose миграц, manifest,
translations.mn-тэй catalog/apps.json бичлэг,
мөн frontend хуудсыг үүсгэнэ — түүнчлэн модулийг apps.Build
(backend/internal/apps/runtime.go) болон menu
blueprint-үүдэд автоматаар холбоно (anchor олдоогүй бол хийх алхмыг
гараар зааж хэвлэнэ). Дараа нь миграцаа ажиллуулж, API-г дахин асаа.
Доорх алхмууд нь генератор юу үүсгэдгийг тайлбарлана.
Алхам алхмаар: шинэ модуль үүсгэх
Алхам 1:
Module Struct тодорхойлж appregistry-д бүртгэх
backend/internal/apps/invoices/invoices.go шинэ директор
үүсгэ.
Модулийн төрөл нь unexported (module,
Module биш): гаднаас харагдах ёстой зүйл нь конструктор ба
internal.Module интерфэйсийн методууд — өөр юу ч биш.
New нь unexported төрөл буцаадаг нь асуудалгүй: угсралт
(apps/runtime.go) утгыг нь internal.Module
болгож хадгална. Дүрмийг
internal/architecture/export_surface_test.go барина.
package invoices
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/gerege-systems/open-gerege-mn-erp/backend/internal"
"github.com/gerege-systems/open-gerege-mn-erp/backend/internal/platform/appregistry"
)
type module struct {
db *pgxpool.Pool
}
func New(db *pgxpool.Pool) *module {
m := &module{db: db}
appregistry.Register(m)
return m
}
func (m *module) ID() string { return "io.example.invoices" }
func (m *module) Name() string { return "Invoicing & Billing" }
func (m *module) Version() string { return "1.0.0" }
func (m *module) Dependencies() []internal.Dependency {
return []internal.Dependency{
{ID: "io.example.contacts", VersionConstraint: "^1.0.0"},
{ID: "io.example.products", VersionConstraint: "^1.0.0"},
}
}
Алхам 2: Permissions болон Menus тодорхойлох
func (m *module) Permissions() []internal.PermissionDefinition {
return []internal.PermissionDefinition{
{Code: "invoices.read", Name: "View Invoices"},
{Code: "invoices.manage", Name: "Create & Edit Invoices"},
}
}
func (m *module) Menus() []internal.MenuDefinition {
return []internal.MenuDefinition{
{ID: "menu_invoices", Label: "Invoices", Path: "/invoices", Icon: "file-text", Order: 30},
}
}
Алхам 3: App Gate Middleware-тэй HTTP Route бүртгэх
RegisterRoutes-д дамжуулагдах middleware нь платформын
app gate юм: сешн баталгаажуулалт + тенантад зориулсан
app_installations шалгалт + эрхийн анхдагч зураглал
(GET/HEAD → <prefix>.read, бусад method →
<prefix>.manage).
func (m *module) RegisterRoutes(r chi.Router, tenantAuthMiddleware func(http.Handler) http.Handler) {
r.Route("/api/v1/invoices", func(sub chi.Router) {
sub.Use(tenantAuthMiddleware)
sub.Get("/", m.handleListInvoices)
sub.Post("/", m.handleCreateInvoice)
})
}
Нээлттэй route ба түүний дүрэм
RegisterRoutes нь root router авдаг —
урьдчилан хаалттай бүлэг биш. Замыг
tenantAuthMiddleware-ийн гадна mount хийх нь нэг мөр бөгөөд
дотор нь mount хийснээс огт ялгарахгүй харагдана.
Энэ нь санаатай: модульд сешнгүй хэрэглэгчид үйлчлэх зам хэрэг болж болно. Гэхдээ өртөг нь — хувийн route санамсаргүй нээлттэй болоход diff дээр юу ч хэлэхгүй. Тиймээс дүрэм:
Сешнгүйгээр хүрч болох route бүр
backend/internal/platform/route_policy_test.go-ийнpublicRoutesжагсаалтад нэрлэгдсэн байх ёстой.
Тест нь бодит routing table-ийг алхаж, route бүрийг ямар ч
credential-гүй дуудаад, жагсаалтад байхгүй атлаа
200/201 хариулсан юм бүхэнд унана. Мөн юу ч
үйлчлэхээ больсон нэр жагсаалтад үлдвэл унана — нэр нь солигдсон route
ард нь орж ирэх дараагийн route-ыг чимээгүй өргөсгөх бичлэг үлдээж
чадахгүй.
Ингэснээр нэр нэмэх нь review дээр харагдах үйлдэл болно. Нэмэхдээ ямар эрх мэдэлд (гарын үсэг, client secret, нэг удаагийн токен г.м.) тулгуурлаж байгааг хажууд нь тайлбарла.
Алхам 3.5: Өгөгдлийн сангийн миграц нэмэх
Хэрэв таны модуль хүснэгт эзэмшдэг бол
backend/db/migrations/control/000NN_<module>.sql дор
goose миграц нэмнэ (дараагийн сул дугаар, -- +goose Up
болон -- +goose Down хоёр хэсэгтэй). Ажиллах үеийн DDL
хориотой — схем зөвхөн миграцаас ирэх ёстой. Хүснэгт бүрийг
tenant_id баганаар хүрээлж, түүнийг unique constraint-д
оруул (UNIQUE (tenant_id, ...)).
Schema-per-tenant шилжилтийн үед (Phase A) бизнес хүснэгт хоёр газар
амьдардаг: control/ (түүхэн public хуулбар) ба
db/migrations/tenant/ (tenant schema бүрт хэрэглэгдэх
шугам). Бизнес хүснэгт нэмэх/өөрчлөх бол ижил DDL-ийг
tenant/-д ШИНЭ дугаартай миграцаар давхар нэм.
0001_tenant_init.sql baseline-ийг бүү засварла.
public руу FK бүү тавь. Tenant schema
бие даасан байх ёстой: tenant_id, created_by
нь control планы id-г зүгээр л хадгална. Нэг ийм FK нь
public.tenants-д хүрсэн бүх statement-ийн түгжээний
олонлогт бүх schema-г оруулдаг — 100 workspace дээр нэг
delete 15 мянган lock авна — бөгөөд provisioning-тэй deadlock үүсгэдэг.
Шалтгааныг tenant/0005 тайлбарласан.
Алхам 4: App Manifest JSON үүсгэх
catalog/manifests/invoices.json manifest файл нэмнэ:
{
"id": "io.example.invoices",
"name": "Invoices",
"version": "1.0.0",
"platform": ">=0.1.0 <2.0.0",
"dependencies": [
{ "id": "io.example.contacts", "version_constraint": "^1.0.0" },
{ "id": "io.example.products", "version_constraint": "^1.0.0" }
]
}
Талбаруудын нэр нь appcatalog.Manifest-тэй яг таарах
ёстой:
| Талбар | Төрөл | Тэмдэглэл |
|---|---|---|
id |
string | Таарах catalog/apps.json бичлэгийн id-тэй
тэнцүү байх ёстой |
version |
string | Хүчинтэй semver |
platform |
string | Платформын хувилбартай (1.0.0) шалгагдах semver
хязгаарлалт |
dependencies |
{id, version_constraint}-ийн
массив |
Объект биш — {} нь задарч чадахгүй |
permissions |
{code, name, description} объектуудын
массив |
Зөвхөн kind: "external" үед. Компайлд
апп үүнийг бичвэл сервер асахаас татгалзана — доорх тэмдэглэлийг үз |
kind |
internal (анхдагч) эсвэл external |
Хоосон нь internal — энэ гарын авлагын бүх зүйл түүнд
хамаарна |
external |
Объект — зөвхөн kind: "external" үед |
Доорх §Гадаад апп |
Файлын нэр нь catalog/manifests/<slug>.json байх
ёстой бөгөөд <slug> нь
catalog/apps.json-д ашигласан slug юм (жижиг үсэг, цифр,
- ба _). Ачаалж чадаагүй эсвэл id
нь каталогийн бичлэгтэй зөрчилдсөн manifest нь асаах үеийн
алдаа болно — сервер апп-ыг хоосон хамаарал, эрх, цэсний
багцтайгаар чимээгүй суулгахын оронд асахаас татгалзана.
Эрх ба цэс manifest-д БАЙХГҮЙ. Компайлд аппын эрхийг
Permissions(), цэсийгMenus()шийднэ — эрхийг хэрэгжүүлдэг код нь түүнийг зарлах ёстой.Урьд нь manifest-д хоёуланг нь бичдэг байсан ба «хоёуланг нь ижилд байлга» гэж энэ гарын авлага зөвлөдөг байв. Тэр зөвлөгөө ажиллаагүй: manifest дэх хуулбарыг юу ч уншдаггүй байсан тул зөрөх нь хаана ч харагдахгүй, улмаар арван manifest бүгд Go кодтойгоо зөрсөн цэсний дараалалтай болсон (жишээ нь billing: manifest 40, код 20). Одоо
menusталбар байхгүй, харин компайлд аппpermissionsбичвэлValidateManifestасаахаас татгалзана — ижилд байлгах цорын ганц найдвартай арга бол хоёр дахь хуулбар байхгүй байх нь.Гадаад апп бол эсрэг тохиолдол: түүнд Go модуль байхгүй тул manifest нь цорын ганц мэдэгдэл —
permissionsтэнд шаардлагатай.
Гадаад апп
(kind: "external")
Аппыг заавал энэ binary дотор компайлдах шаардлагагүй.
kind нь external бол апп өөр газар ажиллаж,
платформтой OAuth2/OIDC-ээр ярина; манифест нь tile хаашаа заахыг, ямар
redirect-тэй болохыг, юу хүсэхийг тодорхойлно:
{
"id": "com.vendor.crm",
"kind": "external",
"external": {
"launch_url": "https://crm.vendor.example/app",
"redirect_uris": ["https://crm.vendor.example/oidc/callback"],
"scopes": ["openid", "contacts.read"],
"event_types": ["billing.invoice.created"],
"webhook_url": "https://crm.vendor.example/hooks/gerege"
}
}
Шалгалт нь manifest уншигдах агшинд — өөрөөр хэлбэл асах үед — хийгдэнэ, хожим хэн нэгэн хаашаа ч хүрдэггүй redirect-ыг ширтэж олохоор биш:
launch_urlүнэмлэхүй байх ёстой (апп өөр газар ажилладаг нь тодорхойлолт нь)- HTTPS заавал; зөвхөн loopback (
localhost,127.0.0.1,::1) онцгой — хөгжүүлэгч каталогоо өөрийн зөөврөө рүү заахад л redirect_urisхамгийн багадаа нэг — эс бөгөөс OAuth client буцах газаргүйevent_typesбичсэн атлааwebhook_urlбайхгүй бол татгалзана- гадаад апп модулийн
dependenciesзарлаж болохгүй — тэр нь API дуудаж байгаа болохоос модулийн графт эрэмбэлэгдэх зүйл биш
Суулгагч нь эдгээрт компайлдсан модуль хайхгүй, эрхийг нь manifest-аас авна.
Алхам 5: Апп-ыг
catalog/apps.json-д бүртгэх
App Store-д шинэ апп-ыг индекслэхийн тулд
catalog/apps.json-д бичлэг нэм. apps өгөгдлийн
сангийн хүснэгт нь асах бүрт тэрхүү файлаас синхрончлогддог тул гараар
SQL хийх шаардлагагүй. Бичлэг бүр эдгээр талбаруудыг агуулна:
{
"id": "io.example.invoices",
"slug": "invoices",
"name": "Invoices",
"description": "Invoice management",
"icon_url": "/icons/invoices.png",
"category": "Finance",
"visibility": "public",
"version": "1.0.0",
"translations": {
"mn": {
"name": "Нэхэмжлэх",
"description": "Нэхэмжлэхийн удирдлага",
"category": "Санхүү"
}
}
}
translations.mn блок нь App Store-ыг монгол хэл
түлхүүтэй болгодог — үүнийг битгий орхи. id нь manifest-ийн
id-тэй, slug нь manifest-ийн файлын нэртэй
тэнцүү байх ёстой.
Алхам 6: Композицийн цэг болон frontend-тэй холбох
Модулийг
apps.Build(backend/internal/apps/runtime.go)-д бусад модулиудын хажууд нэг удаа үүсгээд буцаах slice-д нэм — конструктор ньappregistry-д өөрийгөө бүртгэдэг. Route бүртгэх код бичих шаардлагагүй: сервер модуль бүрийг өөрийнх ньID()-гээр нь app gate-ийн ард залгана.Платформ апп-уудыг import хийхгүй.
internal/platformнь зөвхөнinternal.Moduleинтерфэйсийг мэднэ; багцыг процесс угсарна (cmd/apiдоторplatform.NewServer(db, catalogPath, platform.WithModules(apps.Build))). Энэ нь модулийг салгаж авахад цөмд гар хүрэхгүй байх боломж олгодог бөгөөдbackend/internal/architecture/platform_boundary_test.goдүрмийг хамгаална —internal/platformдоор апп import хийвэл тест унана.Хэрэв таны апп бүлэглэсэн хажуугийн цэс харуулах ёстой бол
backend/internal/platform/menu/-д (blueprintsmap) menu blueprint бүртгэ. Blueprint байхгүй бол таны апп-ын хажуугийн цэс харагдахгүй — яг энэ алдаа нэг удаа гарч байсан (commitc8e8c25, esign модуль).Frontend хуудас нэм: апп-ын үндэс нь
frontend/app/<module>/page.tsx. Blueprint-ийн дэд дэлгэцүүд нь динамикfrontend/app/module/[app]/[feature]/route-оор шийдэгдэнэ; бодит хуудасгүй зам нь "удахгүй" placeholder харуулна.
Бусад модультай харилцах
Өөр модулийн өгөгдөл хэрэгтэй модуль нь тухайн модулийн хүснэгтэд
биш, харин түүний экспортолсон service API-аар дамжих
ёстой. Яагаад ERP бүр үүн рүү нийлдгийг
docs/RESEARCH_ERP_MODULARITY.md-ээс үз.
Хэрэглэгч тал өөрт хэрэгтэй нарийн интерфэйсээ дуудлагын цэг дээр зарлана; угсралт нь модулийн өөрийнх нь конструкторт болно:
// billing declares only what it uses — not contacts' whole surface.
type contactLookup interface {
Get(ctx context.Context, tenantID, id string) (*contacts.Contact, error)
}
func New(db *pgxpool.Pool) *billingModule {
m := &billingModule{db: db, contacts: contacts.NewService(db)}
appregistry.Register(m)
return m
}
Үүнээс дараах дүрмүүд гарна:
- ID-гаар, гадаад түлхүүрээр лавла. Харилцагчийн
хуулбарыг биш,
contact_id-г хадгал. - Хуулийн ач холбогдолтойг snapshot хий. Нэхэмжлэх нь
талыг гарсан агшны байдлаар нь харуулах ёстой тул billing нь шийдэгдсэн
нэрийг
contact_name-д хуулна — хожим нэр солигдсон ч баримтыг дахин бичихгүй. - Хамаарлын чиглэл нэг талын.
billing → contactsнь manifest-д зарлагдана;contactsньbilling-ийн тухай мэдэх ёсгүй. - Master-data модулиуд доод давхаргад үлдэнэ.
contacts,productsболон бусад ньGetдээрErrNotFoundsentinel экспортолж, өөрөөсөө дээших юунаас ч хамаардаггүй — энэ нь модулийн графыг угаасаа ацикл байлгана.
Орчуулга
Хэрэглэгчид харагдах мөр бүр нь
<module>.<kind>.<term> хэлбэрийн
түлхүүртэйгээр
frontend/src/lib/i18n/addons/<app>.ts-д байна.
Компонент дотор шууд текст битгий бич, кодод хэлээр (locale) битгий
салаал. TRANSLATION_GUIDE.md-ийг
үз.
Backend цэсний шошгууд нь монгол текстээ
MenuDefinition-ийн Labels map-д агуулдаг бол
апп-store текст нь catalog/apps.json-д
translations.mn дор байрлана.
Хариуцагчид
- Gerege Systems Development Team (@gerege-systems)
- Gemini AI, Claude AI