3.4 · The centralize bridge
บทนี้เล่าว่า vtrc-api คุยกับ downstream HR service อย่างไร ใช้ transport อะไร auth อย่างไร และทำไมถึงไม่มี fallback
ชื่อโฟลเดอร์ในโค้ดคือ centralize/ และ env var คือ END_POINT_CENTRALIZE — แต่ปลายทางจริงคือ cu-central-api (Node 12 + Apollo GraphQL; CODE_DEFAULT APP_PORT=8080, CLIENT_TARGET ใน vtrc .env.example อาจใช้ 8082) ไม่ใช่ NestJS centralize-api (port 3000 REST)
บทนี้สำคัญเพราะเป็นจุดที่ปัญหา production ส่วนใหญ่เกิด และเป็นจุดที่ชื่อในโค้ดกับชื่อ service จริงไม่ตรงกัน
ชื่อในโค้ด ≠ ชื่อ service จริง
| ชื่อที่เห็นในโค้ด | ความหมายจริง |
|---|---|
โฟลเดอร์ vtrc-api/api/src/centralize/ | bridge layer ฝั่ง vtrc-api |
env END_POINT_CENTRALIZE | URL ของ cu-central-api GraphQL (CLIENT_TARGET ตัวอย่าง http://localhost:8082/api/graphql) |
env END_POINT_CENTRALIZE_AUTH | URL ของ cu-central-api REST auth (คู่กับ END_POINT_CENTRALIZE) |
repo centralize-api/ (NestJS) | service คนละตัว — port 3000 REST — caller จาก vtrc-api ยังไม่ยืนยัน |
หลักฐานจาก .env.example ของ vtrc-api — END_POINT_CENTRALIZE / END_POINT_CENTRALIZE_AUTH ชี้ path /api/graphql และ /auth ของ cu-central-api (CLIENT_TARGET ตัวอย่างใช้ 8082; CODE_DEFAULT ของ cu-central คือ APP_PORT=8080; EXPOSE 9000 ≠ listen; compose มัก 8000) — ไม่ใช่ NestJS centralize-api (ดู บท 5.9)
ภาพที่ถูกต้องในมุม vtrc-api
vtrc-api
│
├──► cu-central-api (GraphQL + REST auth) ← END_POINT_CENTRALIZE
│ └──► MSSQL HRMI
│ └──► LDAP/AD
│
├──► https://hrmi-api.redcross.or.th (legacy REST, TLS off — ดูท้ายบท)
│
├──► process.env.END_POINT_LOG_EVENT (cron, REST)
│
└──► END_POINT_CONICLE (LMS SSO, REST)
(NestJS centralize-api port 3000 — ไม่พบ call จาก vtrc-api ใน workspace นี้)LDAP/AD อยู่ที่ cu-central-api ไม่ใช่ที่ NestJS centralize-api — vtrc-api ไม่ได้ bind LDAP เอง
โครงสร้างโฟลเดอร์ centralize/
api/src/centralize/
├── graphql/
│ ├── queries.js 2,172 บรรทัด — downstream GraphQL query strings
│ ├── mutations.js 376 บรรทัด — downstream GraphQL mutation strings
│ └── index.js 2 บรรทัด — re-export Queries + Mutations
└── controllers/
├── graphql.js 104 บรรทัด — transport shim (2 functions)
├── auth.js 147 บรรทัด — REST auth (signin/signout/refresh)
├── hrmi.js 449 บรรทัด — leave/medical/approver
├── profile.js 283 บรรทัด — employee profile & financial
├── user.js 165 บรรทัด — account/password
├── verification.js 82 บรรทัด — OTP + setup password
├── salaryCert.js 36 บรรทัด
├── slip.js 54 บรรทัด
├── timeAttendance.js 19 บรรทัด
├── tax.js 20 บรรทัด
├── fund.js 30 บรรทัด
├── childTuition.js 54 บรรทัด
├── masterData.js 63 บรรทัด
├── address.js 33 บรรทัด
├── organization.js 29 บรรทัด
├── studyLeaveRequest.js 29 บรรทัด
└── statLog.js 19 บรรทัดรวม ~4,166 บรรทัด — เล็กกว่า controllers layer มาก เพราะส่วนใหญ่เป็น 1-line wrapper ที่เรียกผ่าน shim
Shim 2 ตัว — หัวใจของ bridge
ไฟล์ controllers/graphql.js (104 บรรทัด) export 2 functions ที่ทุก centralize call วิ่งผ่าน
GraphqlCDWithToken — สำหรับ call ที่ต้องมี user token
export const GraphqlCDWithToken = async (query, token, variables, sessionId = null, i = 0, apiKey = null) => {
try {
global.Headers = global.Headers || Headers;
const param = {
...variables
}
let headers = { Authorization: `Bearer ${token}` }
if (apiKey != null) {
headers.apiKey = apiKey
}
const client = new GraphQLClient(END_POINT_CENTRALIZE, {
headers: headers
})
const data = await client.request(query, param)
return datatokenคือSession.tokenCD(bearer ที่ cu-central-api ออกตอน signin)- ถ้ามี
apiKeyด้วย → ส่งเป็น header เสริม - endpoint เดียว —
END_POINT_CENTRALIZEenv (GraphQL URL ของ cu-central-api)
GraphqlCDWithOutToken — สำหรับ call ที่ public (auth, master data)
export const GraphqlCDWithOutToken = async (query, variables, apiKey = null) => {
try {
global.Headers = global.Headers || Headers;
var remainingCounts = null
if (variables.hasOwnProperty('remainingCount')) {
var remainingCounts = variables.remainingCount
delete variables['remainingCount'];
}
let headers = { apiKey: apiKey }
const param = {
...variables
}
const client = new GraphQLClient(END_POINT_CENTRALIZE, {
headers: headers
})
console.log("query ->", query);
console.log("param ->", param);
log.writeLog(JSON.stringify({ query: query, source: "GraphqlCDWithOutToken" }), 'requestCD')
const data = await client.request(query, param)
log.writeLog(JSON.stringify({ data: data, source: "GraphqlCDWithOutToken" }), 'responseCD')
return data- ใช้
apiKeyheader (เท่านั้น) ไม่มี Bearer - เขียน log เต็มรูปแบบ — query string + param + response — ไปยังไฟล์
requestCD/responseCD
Pattern ที่ controller ใช้
// ตัวอย่าง hrmi.js controller
export const hrmiGetMedicalCD = async (tokenCD, variables, sessionId) => {
return await GraphqlCDWithToken(
Queries.medical, // ดึงจาก centralize/graphql/queries.js
tokenCD,
variables,
sessionId,
0, // retry counter (default 0)
API_CENTRALIZE_KEY
)
}controller ทุกตัวใน centralize/controllers/ เป็น 1-3 บรรทัด wrapper แบบนี้ — ไม่มี logic ตัดตอน
REST auth — channel แยก
transport หลักคือ GraphQL แต่มีข้อยกเว้นสำหรับ auth flow ที่ใช้ REST
export const signIn = async (username, password, org) => {
let url = `${END_POINT_CENTRALIZE_AUTH}/signin`
const data = {
username: username,
password: password,
org: org,
apiKey: API_CENTRALIZE_KEY
};
const options = {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
data: qs.stringify(data),
url,
};
const result = await axios(options)REST endpoints 4 ตัวที่ ${END_POINT_CENTRALIZE_AUTH}/...
| Endpoint | Method | Purpose |
|---|---|---|
/signin | POST | login ด้วย username + password |
/signinbypass | POST | login ไม่ต้อง password (สำหรับ mobile bypass) |
/signout | POST | revoke centralize session |
/token | POST | refresh centralize token |
env var คนละตัวกับ GraphQL — END_POINT_CENTRALIZE_AUTH (REST) vs END_POINT_CENTRALIZE (GraphQL) — โดยปกติชี้ไป host เดียวกันของ cu-central-api
apiKey ส่ง 2 ทาง
| Transport | รูปแบบ | ที่มา |
|---|---|---|
| GraphQL | HTTP header apiKey: [key] | graphql.js:19, 79 |
| REST auth | form body field apiKey (urlencoded) | auth.js:33, 56, 78, 102 |
key ตัวเดียวกัน (API_CENTRALIZE_KEY) แต่ส่งคนละ channel — ต้องระวังตอน debug เพราะ log ฝั่ง REST จะเห็น key ใน body แต่ฝั่ง GraphQL จะเห็นใน header
Key tier — 2 ระดับ
| Env var | การใช้งาน |
|---|---|
API_CENTRALIZE_KEY | default — ใช้แทบทุก call |
API_CENTRALIZE_KEY_LEVEL_4 | elevated — ใช้เฉพาะ searchUserCD (user.js:84) ที่ต้องการสิทธิ user search ลึก |
อีก env var — CLIENT_ID_CENTRALIZE — ส่งเฉพาะใน /token refresh (auth.js:103) เป็น client identifier ของ vtrc-api ฝั่ง cu-central-api
Token refresh — inline retry แบบจำกัด
} catch (err) {
const errorCode = err.response.errors[0].extensions.code
if (errorCode == "TokenExpiredError") {
var session
if (sessionId === null) {
session = await Models.Session.findOne({ where: { tokenCD: { [Op.like]: `%${token}%` } } })
} else {
session = await Models.Session.findOne({ where: { sessionId: sessionId } })
}
if (session == null) {
responError("TOKEN_INVALID")
}
const newToken = await refreshToken(session.refreshTokenCD, token, session)
var count = i + 1;
if (count <= 3) {
const result = await GraphqlCDWithToken(query, newToken.accessToken, variables, sessionId, count, apiKey)
return result
} else {
log.error(log.logTextFormat('DATA_NOT_FOUND', 'Method:GraphqlCDWithToken', 'ERROR', err, variables))
responError(errorCode)
}algorithm
- ถ้า cu-central-api ตอบ
TokenExpiredError→ lookup session - เรียก REST
/tokenrefresh → ได้newToken.accessToken - update
Session.tokenCDใน DB (auth.js:96-135) - retry GraphQL call เดิมด้วย token ใหม่
- ทำซ้ำได้สูงสุด 3 ครั้ง (
count <= 3)
Bug ที่ซ่อนอยู่
session = await Models.Session.findOne({ where: { tokenCD: { [Op.like]: `%${token}%` } } })lookup ใช้ Op.like '%${token}%' บน column tokenCD — substring collision เหมือนใน บท 3.3 ถ้า token ของ session A เป็น substring ของ token B อาจจะ update session ผิดตัว
ปกติ token ยาวพอที่จะ collision ยาก แต่ก็เป็น code smell ที่ไม่ควรใช้ LIKE กับ value ที่ exact match ได้
จุดที่ไม่มี — timeout, retry, circuit breaker
grep ทั้ง bridge layer
$ rg "timeout|retries|circuitBreak" vtrc-api/api/src/centralize/
(no matches)new GraphQLClient(END_POINT_CENTRALIZE, ...)— ใช้ default ของ graphql-request (ไม่มี timeout)axios(options)ใน auth.js — ใช้ default ของ axios (ไม่มี timeout)
ผลคือ — ถ้า cu-central-api ช้า vtrc-api ก็ช้าไปด้วย ไม่มี deadline ไม่มี fallback
retry logic มีเฉพาะ token-expired case (ข้างบน) — network error, 5xx, timeout ทั้งหมด propagate ทันทีผ่าน responError
ผลกระทบจริง
ถ้า cu-central-api ล่ม → vtrc-api ก็ใช้งานไม่ได้เลยแม้แต่ field เดียวที่ต้องการ HR data (profile, slip, leave, withdraw, fund, time attendance)
frontend ไม่รู้ว่าเป็น cu-central ล่มหรือ vtrc ล่ม — error ที่เห็นคือ generic GraphQL error
Side channel — 4 ที่ที่ไม่ผ่าน centralize/
มี integration 4 จุดที่ vtrc-api ยิงตรงไป service อื่นโดยไม่ผ่านโฟลเดอร์ centralize/
1. Legacy HRMI REST (TLS off)
ไฟล์ lib/repositories/hrmi/hrmi.js (ไม่ใช่ centralize/controllers/hrmi.js)
// lib/repositories/hrmi/hrmi.js
const axios = require('axios')
const https = require('https')
const instance = axios.create({
httpsAgent: new https.Agent({ rejectUnauthorized: false })
})
export const getToken = async () => {
return await instance.post('https://hrmi-api.redcross.or.th/api/Authenticate/RequestToken', {...})
}rejectUnauthorized: false→ MITM ได้- endpoint เดียวกับที่ cu-central-api / NestJS centralize-api อ่าน (HRMI Center) — เป็น parallel integration
- ดูเหมือนจะเป็น code เก่าที่ไม่ได้ใช้แล้ว เพราะมี
centralize/controllers/hrmi.jsที่ใหม่กว่า
2. Log event API
lib/controllers/statLog/statLog.js ใช้ใน cron ทุกวันที่ 00:30
const result = await axios.get(`${process.env.END_POINT_LOG_EVENT}/log-event-api/api/v1/logEventActive`)- ดึง activation/usage log จาก service ภายนอก
- join กับข้อมูลจาก cu-central แล้ว bulk INSERT ลง
reportActivateAccountใน db2 - SQL string concatenation → injection risk ถ้า log service ถูก compromise
3. Conicle LMS
lib/controllers/conicle/conicle.js — SSO เข้า Conicle ผ่าน cookie + csrf
await axios.post(`${END_POINT_CONICLE}/api/integrate/account/login/`, {
username: CONINCLE_USERNAME,
password: CONINCLE_PASSWORD
})- session cookie ถูกเก็บใน
Models.ConicleSession(DB) - env var มี typo —
CONINCLE_USERNAME(ผิดจากCONICLE)
4. Firebase Cloud Messaging
lib/firebase/firebasePushNotification.js ใช้ส่ง push notification ผ่าน FCM
await axios.post(`https://fcm.googleapis.com/fcm/send`, payload, {
headers: { Authorization: `key=${serverKey}` }
})- ใช้ legacy HTTP v1 API (deprecated)
- service account key เก็บใน
vtrc-gcp-sa-key.json(committed)
Caching — Redis layer ที่ตายแล้ว
มี Redis helper (lib/redis.js, lib/controllers/redis.controller.js) แต่ call site ทุกที่ถูก comment ไว้
| ที่ | สถานะ |
|---|---|
routes.js:411-416, 441-442 | commented |
resolvers/post.js:39-43, 103-107 | commented |
resolvers/course.js:36-40, 57-61 | commented |
lib/controllers/file/file.js:314-315 | commented |
resolvers/hrmi.js:7 | import เองก็ commented |
ผล — ไม่มี cache layer ระหว่าง vtrc-api กับ cu-central-api ทุก request ยิง cu-central ใหม่
Bug ที่ซ่อนอยู่ — ถ้า uncomment จะพัง
trมี tr token ค้างอยู่ใน getCacheData — ถ้าใคร uncomment cache call site จะ ReferenceError: tr is not defined ทันที
ตารางสรุป — call sites ทั้งหมด
| ไฟล์:บรรทัด | Lib | เป้าหมาย |
|---|---|---|
centralize/controllers/graphql.js:21, 84 | graphql-request | cu-central-api (GraphQL) |
centralize/controllers/auth.js:28, 52, 75, 98 | axios | cu-central-api (REST auth) |
lib/repositories/hrmi/hrmi.js:12, 34 | axios | hrmi-api.redcross.or.th (legacy, TLS off) |
lib/controllers/statLog/statLog.js:12, 88, 164 | axios | END_POINT_LOG_EVENT (cron) |
lib/controllers/conicle/conicle.js | axios | END_POINT_CONICLE (LMS SSO) |
lib/firebase/firebasePushNotification.js:3 | axios | FCM |
routes.js:36 | axios | REST file-download endpoints |
เคล็ดลับตอนทำงานจริง
ถ้า debug "field นี้ช้า" — ดูก่อนว่าผ่าน centralize bridge
field ที่ต้องการ HR data (profile, slip, leave, withdraw) ผ่าน cu-central-api ทุกครั้ง — ถ้า cu-central ช้า vtrc-api ก็ช้าด้วย เครื่องมือที่ช่วยคือ log ใน centralize/controllers/graphql.js:87-91 ที่ log query + param + response
ถ้าเห็น TokenExpiredError มากใน log
อาจจะเป็นเพราะ refresh retry วนลูป — check count <= 3 ใน graphql.js:41 ว่าครบ 3 รอบหรือเปล่า ถ้าครบแล้ว fail ทุกครั้ง → Session.tokenCD หมดอายุโดยแท้ ต้อง logout + login ใหม่
อย่าเพิ่ม cache โดย uncomment
getCacheData พัง (tr token) — ต้องแก้ก่อน แล้วก็ต้องคิดด้วยว่า cache key จะ invalidate อย่างไรถ้าข้อมูลจาก cu-central เปลี่ยน
ถ้าจะเขียน controller ใหม่ที่เรียก cu-central
- เขียน query string ใน
centralize/graphql/queries.jsหรือmutations.js - เขียน wrapper ใน
centralize/controllers/[domain].jsที่เรียกผ่านGraphqlCDWithTokenหรือGraphqlCDWithOutToken - เรียก wrapper จาก controller ใน
lib/controllers/ - อย่ายิง axios ตรงจาก
lib/controllers/— ให้ผ่าน centralize layer เสมอ เพราะจะได้ log + retry logic
ระวัง "legacy" hrmi.js
lib/repositories/hrmi/hrmi.js (เก่า) กับ centralize/controllers/hrmi.js (ใหม่) เป็น 2 integration ขนานไปยัง HRMI เดียวกัน — ถ้าจะ refactor ให้ยกเลิกตัวเก่าแล้วไปใช้ตัวใหม่เท่านั้น แต่ต้องเช็ก call site ทุกที่ก่อน
console.log รั่วแบบเงียบ
centralize/controllers/graphql.js:87-88 log query + param ทุก GraphqlCDWithOutToken call — ถ้า param มี PII (empCode, identityCard) จะตกไปอยู่ใน production log
อย่าสับสนกับ NestJS centralize-api
ถ้าเห็น repo centralize-api/ หรือ Swagger ที่ /docs — นั่นคือ service คนละตัว (port 3000 REST) ไม่ใช่ปลายทางของ END_POINT_CENTRALIZE ดู Volume 5