Skip to content

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_CENTRALIZEURL ของ cu-central-api GraphQL (CLIENT_TARGET ตัวอย่าง http://localhost:8082/api/graphql)
env END_POINT_CENTRALIZE_AUTHURL ของ 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

10:25:vtrc-api/api/src/centralize/controllers/graphql.js
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 data
  • token คือ Session.tokenCD (bearer ที่ cu-central-api ออกตอน signin)
  • ถ้ามี apiKey ด้วย → ส่งเป็น header เสริม
  • endpoint เดียว — END_POINT_CENTRALIZE env (GraphQL URL ของ cu-central-api)

GraphqlCDWithOutToken — สำหรับ call ที่ public (auth, master data)

71:92:vtrc-api/api/src/centralize/controllers/graphql.js
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

Pattern ที่ controller ใช้

javascript
// ตัวอย่าง 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

27:49:vtrc-api/api/src/centralize/controllers/auth.js
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}/...

EndpointMethodPurpose
/signinPOSTlogin ด้วย username + password
/signinbypassPOSTlogin ไม่ต้อง password (สำหรับ mobile bypass)
/signoutPOSTrevoke centralize session
/tokenPOSTrefresh centralize token

env var คนละตัวกับ GraphQLEND_POINT_CENTRALIZE_AUTH (REST) vs END_POINT_CENTRALIZE (GraphQL) — โดยปกติชี้ไป host เดียวกันของ cu-central-api

apiKey ส่ง 2 ทาง

Transportรูปแบบที่มา
GraphQLHTTP header apiKey: [key]graphql.js:19, 79
REST authform 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_KEYdefault — ใช้แทบทุก call
API_CENTRALIZE_KEY_LEVEL_4elevated — ใช้เฉพาะ 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 แบบจำกัด

26:47:vtrc-api/api/src/centralize/controllers/graphql.js
  } 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

  1. ถ้า cu-central-api ตอบ TokenExpiredError → lookup session
  2. เรียก REST /token refresh → ได้ newToken.accessToken
  3. update Session.tokenCD ใน DB (auth.js:96-135)
  4. retry GraphQL call เดิมด้วย token ใหม่
  5. ทำซ้ำได้สูงสุด 3 ครั้ง (count <= 3)

Bug ที่ซ่อนอยู่

31:31:vtrc-api/api/src/centralize/controllers/graphql.js
        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)

javascript
// 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

javascript
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

javascript
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

javascript
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-442commented
resolvers/post.js:39-43, 103-107commented
resolvers/course.js:36-40, 57-61commented
lib/controllers/file/file.js:314-315commented
resolvers/hrmi.js:7import เองก็ commented

ผล — ไม่มี cache layer ระหว่าง vtrc-api กับ cu-central-api ทุก request ยิง cu-central ใหม่

Bug ที่ซ่อนอยู่ — ถ้า uncomment จะพัง

16:16:vtrc-api/api/src/lib/controllers/redis.controller.js
    tr

มี tr token ค้างอยู่ใน getCacheData — ถ้าใคร uncomment cache call site จะ ReferenceError: tr is not defined ทันที


ตารางสรุป — call sites ทั้งหมด

ไฟล์:บรรทัดLibเป้าหมาย
centralize/controllers/graphql.js:21, 84graphql-requestcu-central-api (GraphQL)
centralize/controllers/auth.js:28, 52, 75, 98axioscu-central-api (REST auth)
lib/repositories/hrmi/hrmi.js:12, 34axioshrmi-api.redcross.or.th (legacy, TLS off)
lib/controllers/statLog/statLog.js:12, 88, 164axiosEND_POINT_LOG_EVENT (cron)
lib/controllers/conicle/conicle.jsaxiosEND_POINT_CONICLE (LMS SSO)
lib/firebase/firebasePushNotification.js:3axiosFCM
routes.js:36axiosREST 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

  1. เขียน query string ใน centralize/graphql/queries.js หรือ mutations.js
  2. เขียน wrapper ใน centralize/controllers/[domain].js ที่เรียกผ่าน GraphqlCDWithToken หรือ GraphqlCDWithOutToken
  3. เรียก wrapper จาก controller ใน lib/controllers/
  4. อย่ายิง 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


ขั้นตอนถัดไป

ไป บท 3.5 Persistence layer