3.1.4 · The centralize bridge
บทนี้เล่าว่า vtrc-api คุยกับ downstream HR service อย่างไร ใช้ transport อะไร auth อย่างไร และทำไมถึงไม่มี fallback
ชื่อโฟลเดอร์ในโค้ดคือ centralize/ และ env var คือ END_POINT_CENTRALIZE — แต่ปลายทางจริงคือ cu-central-api (Apollo GraphQL; APP_PORT default 8080 ตามโค้ด, .env.example ของ vtrc-api ชี้ตัวอย่างที่ 8082) ไม่ใช่ NestJS centralize-api (REST, port 3000) — ทุก citation ในบทนี้ re-verify ตรงกับโค้ดปัจจุบัน (2026-07-13)
ชื่อในโค้ด ≠ ชื่อ service จริง
| ชื่อที่เห็นในโค้ด | ความหมายจริง |
|---|---|
โฟลเดอร์ vtrc-api/api/src/centralize/ | bridge layer ฝั่ง vtrc-api |
env END_POINT_CENTRALIZE | URL ของ cu-central-api GraphQL |
env END_POINT_CENTRALIZE_AUTH | URL ของ cu-central-api REST auth |
repo centralize-api/ (NestJS) | service คนละตัว — port 3000 REST — caller จาก vtrc-api ไม่ยืนยัน (debt E-08) |
vtrc-api/api/.env.example:36-37
END_POINT_CENTRALIZE="http://localhost:8082/api/graphql"
END_POINT_CENTRALIZE_AUTH="http://localhost:8082/auth"path /api/graphql และ /auth ชี้ cu-central-api ไม่ใช่ NestJS centralize-api ดู Volume 3.3 centralize-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/
verify จำนวนไฟล์และ LOC ตรงกับโค้ดปัจจุบันทั้งหมด (2026-07-13)
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/ 17 ไฟล์ รวม 4,166 บรรทัด
├── 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
├── slip.js 54 บรรทัด
├── childTuition.js 54 บรรทัด
├── masterData.js 63 บรรทัด
├── salaryCert.js 36 บรรทัด
├── address.js 33 บรรทัด
├── fund.js 30 บรรทัด
├── organization.js 29 บรรทัด
├── studyLeaveRequest.js 29 บรรทัด
├── tax.js 20 บรรทัด
├── timeAttendance.js 19 บรรทัด
└── statLog.js 19 บรรทัดเล็กกว่า controllers layer มาก เพราะส่วนใหญ่เป็น 1-line wrapper ที่เรียกผ่าน shim
Shim 2 ตัว — หัวใจของ bridge
ไฟล์ controllers/graphql.js (104 บรรทัด) export 2 functions ที่ทุก centralize call วิ่งผ่าน
GraphqlCDWithToken — สำหรับ call ที่ต้องมี user token
vtrc-api/api/src/centralize/controllers/graphql.js:10-25
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_CENTRALIZE
GraphqlCDWithOutToken — สำหรับ call ที่ public (auth, master data)
vtrc-api/api/src/centralize/controllers/graphql.js:71-92
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ใช้ apiKey header เท่านั้น ไม่มี Bearer และเขียน log เต็มรูปแบบ (query string + param + response) ไปยังไฟล์ requestCD / responseCD
controller ทุกตัวใน centralize/controllers/ เป็น 1-3 บรรทัด wrapper ที่เรียกผ่าน shim ตัวใดตัวหนึ่ง — ไม่มี logic ตัดตอน
REST auth — channel แยก
vtrc-api/api/src/centralize/controllers/auth.js:27-49
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] |
| REST auth | form body field apiKey (urlencoded) |
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 search ลึก |
อีก env var — CLIENT_ID_CENTRALIZE — ส่งเฉพาะใน /token refresh เป็น client identifier ของ vtrc-api ฝั่ง cu-central-api
Token refresh — inline retry แบบจำกัด
vtrc-api/api/src/centralize/controllers/graphql.js:26-47
} 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 /token refresh → update Session.tokenCD → retry GraphQL call เดิมด้วย token ใหม่ ทำซ้ำได้สูงสุด 3 ครั้ง
Bug ที่ซ่อนอยู่
vtrc-api/api/src/centralize/controllers/graphql.js:31-31
session = await Models.Session.findOne({ where: { tokenCD: { [Op.like]: `%${token}%` } } })lookup ใช้ Op.like '%${token}%' บน column tokenCD — substring collision เหมือนใน 3.1.2 ถ้า token ของ session A เป็น substring ของ token B อาจ update session ผิดตัว ปกติ token ยาวพอที่จะ collision ยาก แต่เป็น code smell
จุดที่ไม่มี — timeout, retry, circuit breaker
new GraphQLClient(END_POINT_CENTRALIZE, ...) ใช้ default ของ graphql-request (ไม่มี timeout) axios(options) ใน auth.js ก็ใช้ default ของ axios (ไม่มี timeout) — 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/
1. Legacy HRMI REST (TLS off)
ไฟล์ lib/repositories/hrmi/hrmi.js (ไม่ใช่ centralize/controllers/hrmi.js) ใช้ https.Agent({ rejectUnauthorized: false }) → MITM ได้ ยิงไปยัง hrmi-api.redcross.or.th เดียวกับที่ cu-central-api อ่าน (parallel integration ที่ดูเหมือนเป็นโค้ดเก่าที่ยังไม่ได้ลบ)
2. Log event API
lib/controllers/statLog/statLog.js (cron ทุกวัน 00:30) ดึง log จาก process.env.END_POINT_LOG_EVENT แล้ว join กับ cu-central แล้ว bulk INSERT ลง reportActivateAccount ใน db2 — SQL string concatenation risk (ดู 3.1.8)
3. Conicle LMS
lib/controllers/conicle/conicle.js — SSO เข้า Conicle ผ่าน cookie + csrf session cookie เก็บใน Models.ConicleSession env var มี typo — CONINCLE_USERNAME (ผิดจาก CONICLE)
4. Firebase Cloud Messaging
lib/firebase/firebasePushNotification.js ส่ง push ผ่าน FCM legacy HTTP v1 API (deprecated) service account key เก็บใน vtrc-gcp-sa-key.json (committed — ห้าม log ห้าม paste)
Caching — Redis layer ที่ตายแล้ว
Redis helper (lib/redis.js, lib/controllers/redis.controller.js) มีอยู่ แต่ call site ทุกที่ถูก comment ไว้ (routes.js, resolvers/post.js, resolvers/course.js, lib/controllers/file/file.js, resolvers/hrmi.js) — ผลคือ ไม่มี cache layer ระหว่าง vtrc-api กับ cu-central-api ทุก request ยิง cu-central ใหม่
getCacheData มี tr token ค้าง (ดู 3.1.9) — ถ้าใคร uncomment cache call site จะ ReferenceError: tr is not defined ทันที
ตารางสรุป — call sites ทั้งหมด
| Lib | เป้าหมาย |
|---|---|
centralize/controllers/graphql.js (graphql-request) | cu-central-api (GraphQL) |
centralize/controllers/auth.js (axios) | cu-central-api (REST auth) |
lib/repositories/hrmi/hrmi.js (axios) | hrmi-api.redcross.or.th (legacy, TLS off) |
lib/controllers/statLog/statLog.js (axios) | END_POINT_LOG_EVENT (cron) |
lib/controllers/conicle/conicle.js (axios) | END_POINT_CONICLE (LMS SSO) |
lib/firebase/firebasePushNotification.js (axios) | FCM |
routes.js (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 log query + param + response
ถ้าเห็น TokenExpiredError มากใน log
อาจเป็น refresh retry วนลูป check count <= 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
อย่าสับสนกับ NestJS centralize-api
ถ้าเห็น repo centralize-api/ หรือ Swagger ที่ /docs — service คนละตัว (port 3000 REST) ไม่ใช่ปลายทางของ END_POINT_CENTRALIZE ดู Volume 3.3