การสร้าง Service¶
ขั้นตอนสร้าง service ใหม่จาก gumon-backend-template (NestJS) ตั้งแต่ clone จนเสียบเข้าแอปและใช้งานได้
service จะเขียนด้วยภาษา/framework อื่นก็ได้ ขอแค่คุยผ่าน Kafka ตาม สิ่งที่ Service ต้องมี — template แค่ทำให้เริ่มเร็ว
ขั้นตอน¶
1. เริ่มจาก template¶
clone repo gumon-backend-template (branch master) แล้วตั้งชื่อ service
src/constants/serviceKey.ts→export const SERVICE_KEY: string = 'my-service';(kebab-case · ตรงกับที่จะลงทะเบียนกับ core) ค่าเริ่มต้นCHANGE_MEทำให้ service บูตไม่ขึ้นโดยตั้งใจ — กันลืมตั้งpackage.json→"name": "gumon-my-service-service"
stack: NestJS + GraphQL (schema-first) + MongoDB (Mongoose) + Redis + Kafka (kafkajs)
โครงโฟลเดอร์หลัก
| โฟลเดอร์ | ใช้ทำอะไร |
|---|---|
src/graphqls/<module>/ |
*.graphql, resolver, service, dto ของ API |
src/constants/ |
serviceKey.ts, kafka/kafkaTopic.ts, permissionKey.ts, permissions.ts, redisKey.ts |
src/database/schemas/ |
schema ของ MongoDB (collection ขึ้นต้นด้วย <serviceKey>_) |
src/kafka/ |
producer / consumer wrapper |
src/kafka-consumer/<topic>/ |
1 โฟลเดอร์ต่อ 1 topic ที่รับ |
src/guards/ |
ยืนยันตัวตน + ตรวจ UserPolicy |
scripts/ |
dev-keys.ts · gumon-register.ts |
test/ |
e2e + docker-compose.e2e.yml |
AI ที่ช่วยเขียนโค้ดอ่านกติกาจาก AGENTS.md และ .github/skills/ ใน repo ได้ทันที
2. รันในเครื่อง (ยังไม่ต้องมี core)¶
template มี Kafka + MongoDB (replica set) + Redis ใน Docker และกุญแจสำหรับใช้ในเครื่องให้พร้อม
pnpm install
pnpm generate # สร้าง typings จาก *.graphql
pnpm test:e2e:up # เปิด Kafka (localhost:19092) · MongoDB (localhost:27099) · Redis (localhost:6399)
pnpm dev:keys # สร้างกุญแจ .gumon สำหรับใช้ในเครื่อง
pnpm start:dev
.env สำหรับรันในเครื่อง (ตั้งต้นจาก .env.example)
DB_URI='mongodb://localhost:27099/dev?directConnection=true'
KAFKA_BROKERS='localhost:19092'
REDIS_HOST='localhost'
REDIS_PORT='6399'
GRAPHQL_PLAYGROUND='true'
- กุญแจจาก
dev:keysมีรูปแบบเดียวกับที่ core ออก แต่ core จริงไม่รู้จัก — ใช้ในเครื่องและในเทสต์เท่านั้น dev:keysไม่เขียนทับกุญแจที่มีอยู่ (ใส่--forceเมื่อแน่ใจ)- ปิด infra:
pnpm test:e2e:down(ลบข้อมูลด้วย)
ทดสอบ: pnpm test (unit) · pnpm test:e2e (บูต service จริงแล้วคุยผ่าน Kafka เหมือน service อื่น)
3. ลงทะเบียน service กับ core (ระดับระบบ)¶
ด้วยคำสั่ง — ใช้ได้ทั้ง core ในเครื่องและบน server
cp .env.register.example .env.register # กรอก GUMON_CORE_URL + credential (git ไม่เก็บไฟล์นี้)
pnpm gumon:register --dry-run # ดูสิ่งที่จะส่ง (ซ่อนค่าลับ)
pnpm gumon:register # เรียก registerService แล้วเขียนกุญแจลง .gumon/certificates/<serviceKey>/
- ยืนยันตัวด้วย credential ชนิด
SYSTEM(GUMON_CLIENT_ID+GUMON_CLIENT_SECRET) หรือ access token ของผู้ดูแล (GUMON_ACCESS_TOKEN) — ผู้เรียกต้องมีสิทธิ์registerService - service ที่มีหน้า admin ใส่
GUMON_SERVICE_TYPE,GUMON_URL_FRONTEND,GUMON_URL_GET_METADATAเพิ่ม
หรือผ่านหน้าเว็บ — system admin › Service › register (GraphQL registerService)
แล้วนำกุญแจไปวางใน .gumon/certificates/<serviceKey>/ (certificate-id.key, certificate, certificate.pub, hash.key, symmetric.key)
ทั้งสองทาง: serviceKey = ค่าเดียวกับ SERVICE_KEY · isCoreSet: false · กุญแจได้ครั้งเดียว ห้าม commit .gumon/
ตอนเริ่มทำงาน service ส่ง register-service ประกาศตัวกับ core · ตรวจสถานะได้ที่ system admin › Service-ready
4. เพิ่ม service เข้าแอป (ระดับแอป)¶
ที่หน้า system admin › app › app-service › add (หรือ addServiceToApp กับ refreshData: true)
service จะได้รับ sync-app-certificate, sync-app-credential, sync-application, sync-user-policy, sync-app-service-setting ของแอปนั้น ⇒ ตรวจ token ของผู้ใช้ในแอปได้ทันที
5. ผูก permission กับ role¶
refresh-data ทำให้ service ส่ง sync-permission ให้ access-control → ผู้ดูแลผูก permission เข้ากับ role → access-control ส่ง sync-user-policy กลับมา ⇒ ผู้ใช้ที่มี role นั้นเรียก API ได้
6. เขียนงานของ service¶
เพิ่ม API (API Reference) และ topic (Kafka) · ใช้บริการกลางผ่าน Kafka แทนการทำเอง (ตั้งเวลา → set-schedule, แจ้งเตือน → create-notification, ไฟล์ → sync-file-upload)
7. (ถ้ามี) หน้า admin¶
ทำหน้า admin ย่อยจาก gumon-dynamic-admin-iframe-template เปิด GET /api/menuMetaData แล้วลงทะเบียนพร้อม urlFrontend, urlGetMetaData ดู เมนูของหน้า admin
API Reference¶
API ของ service เป็น GraphQL ที่ POST /graphql · หน้าบ้านเรียกได้ตรง · service อื่นห้ามเรียก
Query / Mutation¶
- เขียน schema ใน
src/graphqls/<module>/<module>.graphqlแล้วpnpm generate - เพิ่ม permission ของ operation ใน
src/constants/permissionKey.tsและรายละเอียดในpermissions.ts
// permissionKey.ts
export const PERMISSION_KEY = {
getOrders: 'getOrders',
createOrder: 'createOrder',
};
// permissions.ts
export const PERMISSIONS: IPermission[] = [
{
permissionKey: PERMISSION_KEY.createOrder,
title: 'Create order',
description: 'สร้างคำสั่งซื้อ',
isGenerateApplication: true, // ให้สิทธิ์ทั้งแอปได้ (ผ่าน appRole)
isGenerateOrganization: false, // ให้สิทธิ์แยกตามองค์กรได้ (ผ่าน organizationRole)
systemNote: '',
},
];
- resolver ใช้
AuthGuard(ต้อง login) หรือAppCredentialGuard(ยอมรับX-APP-CLIENT-IDสำหรับข้อมูลที่อ่านได้ก่อน login) - ใน service ตรวจสิทธิ์ก่อนทำงาน
await this.verifyUserPolicyService.verifyUserPolicy({
appKey,
authId,
permissionKey: PERMISSION_KEY.createOrder,
// organizationId, // ถ้าตรวจระดับองค์กร
});
ระดับแอป หรือ ระดับองค์กร
| ตั้ง | ใช้เมื่อ | ตอนตรวจ |
|---|---|---|
isGenerateApplication: true |
สิทธิ์นี้ใช้ได้ทั้งแอป (เช่น ผู้ดูแลแอปจัดการข้อมูลทุกองค์กร) | verifyUserPolicy({ appKey, authId, permissionKey }) |
isGenerateOrganization: true |
สิทธิ์นี้ให้เฉพาะในองค์กร (เช่น พนักงานสาขา A จัดการได้แค่สาขา A) | ใส่ organizationId ขององค์กรที่ข้อมูลนั้นอยู่ |
ตั้ง true ทั้งคู่ได้ ⇒ ผู้ดูแลเลือกผูกได้ทั้งสองระดับ
ข้อมูลของใครของมัน (เช่น ผู้ป่วยเห็นเฉพาะนัดของตัวเอง) — permission ตอบได้แค่ "ทำ X ได้ไหม" ไม่ได้ตรวจว่าเป็นเจ้าของ
ให้ service กรองเองด้วย authId จาก token (ctx.user.authId) เช่น query getMyAppointments คืนเฉพาะรายการที่ patientAuthId === authId · ตั้งชื่อ API แบบ getMy… ให้รู้ว่าเป็นข้อมูลของคนที่ login
query ที่เป็นรายการใช้ input รูปเดียวกับ service อื่น { filter, search, sort: { sortBy, sortOrder }, pagination: { page, limit } }
kafka consume Reference¶
topic ที่ต้องรับแบ่งตามระดับใน สิ่งที่ Service ต้องมี — ทุกตัว: sync-app-certificate, refresh-data · มี API: sync-app-credential, sync-user-policy · ตามการใช้งาน: sync-application, sync-organization, sync-auth, sync-service-setting, sync-app-service-setting, schedule-alarm · payload ดูที่ Kafka topic มาตรฐาน
เพิ่ม topic ที่รับ
- เพิ่มชื่อใน
KAFKA_TOPIC_CONSUMER(src/constants/kafka/kafkaTopic.ts) - สร้าง
src/kafka-consumer/<topic>/<topic>.module.ts+<topic>.service.tsแล้ว import module ในapp.module.ts - ใน
onModuleInitเรียกawait this.kafkaConsumerService.kafkaInitProcess(topic, this.processMessage.bind(this))· consumer group จะเป็น<serviceKey>-consumer-<topic>· ต้องawaitเพื่อให้บูตล้มชัด ๆ ถ้าต่อ Kafka ไม่ได้ - ใน
processMessage(headers, message):- เฉพาะ topic ที่ส่งถึง service เดียว → ทิ้งถ้า
headers.serviceKey !== SERVICE_KEY· topic กระจายทั้งแอป (sync-application,sync-auth,sync-<entity>ของ service อื่น ฯลฯ) ห้ามกรอง — ดูตารางใน สิ่งที่ Service ต้องมี - ทิ้งถ้าไม่มี App Certificate ของตัวเองใน
headers.appKey(ใช้kafkaConsumerHelper.getAppCertificate(appKey, SERVICE_KEY)) - แยกงานตาม
action(ADD,REMOVE, ...) แล้วบันทึกสำเนาลง DB ของตัวเอง โดยห่อด้วยrunInTransaction(connection, async (session) => { ... })— commit / abort / ปิด session ให้เสมอ - ล้าง Redis key ที่เกี่ยวข้อง หลัง transaction commit แล้ว
- เฉพาะ topic ที่ส่งถึง service เดียว → ทิ้งถ้า
ตัวอย่าง consumer ของข้อมูลที่ service อื่นประกาศ (กระจายทั้งแอป จึงไม่กรอง serviceKey)
async processMessage(headers: IKafkaHeaders, message: string) {
const { action, appointment } = JSON.parse(message); // JSON ผิดรูป → throw → ระบบลองซ้ำ/dead-letter ให้
const appCertificate = await this.kafkaConsumerHelper.getAppCertificate(headers.appKey, SERVICE_KEY);
if (!appCertificate) return null; // เราไม่ได้อยู่ในแอปนี้
await runInTransaction(this.sectionConnection, async (session) => {
if (action === 'ADD') {
await this.appointmentModel.findByIdAndUpdate(appointment.id, appointment, { upsert: true, session });
}
if (action === 'REMOVE') {
await this.appointmentModel.findByIdAndDelete(appointment.id, { session });
}
});
await deleteRedisKeysWithPrefix({ prefix: `${SERVICE_KEY}:appointment:`, redis: this.redis }); // หลัง commit
}
เมื่อประมวลผลไม่สำเร็จ ให้ throw ได้เลย — KafkaConsumerService จัดการให้
| สถานการณ์ | ระบบทำอะไร |
|---|---|
| handler throw (ข้อมูลผิดรูป · DB ล่มชั่วคราว ฯลฯ) | ลองซ้ำแบบเว้นระยะ KAFKA_MESSAGE_MAX_RETRY ครั้ง (ค่าเริ่มต้น 3) |
| ยังไม่สำเร็จ | ส่งไป KAFKA_DEAD_LETTER_TOPIC (ถ้าตั้ง) พร้อม header บอก topic / offset / สาเหตุ แล้วไปข้อความถัดไป — ข้อความเสียข้อความเดียวไม่ทำให้ทั้ง topic ค้าง |
| consumer ล่มแบบกู้ไม่ได้ | ปิด process ให้ระบบ (เช่น k8s) เริ่มใหม่ — ไม่ปล่อยให้ service รันต่อโดยไม่รับ topic นั้น |
⛔ อย่า catch แล้วกลืน error เงียบ ๆ — ข้อมูลจะหายโดยไม่มีใครรู้
Kafka Produce Reference¶
topic ที่ต้องส่ง: register-service + hand-check-result (ทุกตัว · template ทำให้แล้ว) · sync-permission (ถ้ามี API) · sync-<entity> ของข้อมูลที่ตัวเองเป็นเจ้าของ (ส่งเมื่อเปลี่ยน และส่งซ้ำทั้งชุดเมื่อได้ refresh-data) · payload ดูที่ Kafka topic มาตรฐาน
เพิ่ม topic ที่ส่ง: เพิ่มชื่อใน KAFKA_TOPIC_PRODUCE แล้วส่งด้วย
await this.kafkaProducerService.produceApp({
topic: KAFKA_TOPIC_PRODUCE.setSchedule,
headerAppKey: appKey,
headerServiceKey: 'schedule', // service ปลายทาง ไม่ใช่ SERVICE_KEY ของตัวเอง
dataArray: [{ action: 'ADD', schedule: { /* ... */ } }],
});
ประกาศข้อมูลของตัวเองให้ service อื่น (sync-<entity>) — กระจายทั้งแอป ใส่เฉพาะ headerAppKey
// ส่งทุกครั้งที่สร้าง / แก้ / ยกเลิก และส่งซ้ำทั้งชุดเมื่อได้ refresh-data
await this.kafkaProducerService.produceApp({
topic: 'sync-appointment',
headerAppKey: appKey, // ไม่ใส่ headerServiceKey = ถึงทุก service ในแอป
dataArray: [{
action: 'ADD', // ADD = สร้างหรือแก้ (ผู้รับ upsert ตาม id) · REMOVE = ลบออก
appointment: { id, appKey, status: 'CANCELLED', patientAuthId, startAt },
}],
});
- ข้อมูลที่ยังอยู่แต่เปลี่ยนสถานะ (เช่น ยกเลิกนัด) ส่ง
ADDพร้อมstatus· ใช้REMOVEเมื่อลบข้อมูลออกจริง - payload ใส่ข้อมูลที่ service อื่นต้องใช้ให้ครบ ผู้รับจะเก็บสำเนาไว้ ไม่ย้อนมาถามเรา
- ชื่อ topic:
sync-<entity>(kebab-case) · ประกาศ payload ไว้ใน README ของ service
Checklist¶
- [ ] ตั้ง
SERVICE_KEYและชื่อ package แล้ว (ไม่ใช่CHANGE_ME) - [ ]
.gumon/certificates/<serviceKey>/เป็นกุญแจจาก core (ไม่ใช่กุญแจจากdev:keys) และอยู่ใน.gitignore - [ ] ส่ง
register-serviceตอนเริ่ม - [ ] รับ
sync-app-certificateและrefresh-data(ทุกตัว) · รับsync-app-credentialและsync-user-policy(ถ้ามี API) - [ ]
refresh-data: กันทำซ้ำด้วยrefreshDataId, ส่งsync-permission+ ข้อมูลของตัวเองใหม่, ล้าง Redis<serviceKey>: - [ ] ทุก API ตรวจ permission ผ่าน UserPolicy และทุก permission อยู่ใน
permissions.ts - [ ] header
serviceKey= service ปลายทาง ทุกข้อความที่ส่ง - [ ] ไม่เรียก API ของ service อื่นตรง · ไม่ตั้ง cron เอง (ใช้ schedule)
- [ ] consumer เขียน DB ผ่าน
runInTransactionและไม่กลืน error - [ ] บน server:
GRAPHQL_PLAYGROUNDไม่ตั้งหรือเป็นfalse·BY_PASS_ACL='false'· ตั้งKAFKA_DEAD_LETTER_TOPIC - [ ]
pnpm testและpnpm test:e2eผ่าน
อัปเดตจากโค้ด gumon-backend-template@f1ae0e8 · 2026-10-06