Модуль зохиох гарын авлага

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-тэй холбох

  1. Модулийг 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 хийвэл тест унана.

  2. Хэрэв таны апп бүлэглэсэн хажуугийн цэс харуулах ёстой бол backend/internal/platform/menu/-д (blueprints map) menu blueprint бүртгэ. Blueprint байхгүй бол таны апп-ын хажуугийн цэс харагдахгүй — яг энэ алдаа нэг удаа гарч байсан (commit c8e8c25, esign модуль).

  3. 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 дээр ErrNotFound sentinel экспортолж, өөрөөсөө дээших юунаас ч хамаардаггүй — энэ нь модулийн графыг угаасаа ацикл байлгана.

Орчуулга

Хэрэглэгчид харагдах мөр бүр нь <module>.<kind>.<term> хэлбэрийн түлхүүртэйгээр frontend/src/lib/i18n/addons/<app>.ts-д байна. Компонент дотор шууд текст битгий бич, кодод хэлээр (locale) битгий салаал. TRANSLATION_GUIDE.md-ийг үз.

Backend цэсний шошгууд нь монгол текстээ MenuDefinition-ийн Labels map-д агуулдаг бол апп-store текст нь catalog/apps.jsontranslations.mn дор байрлана.


Хариуцагчид