Skip to content

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_CENTRALIZEURL ของ cu-central-api GraphQL
env END_POINT_CENTRALIZE_AUTHURL ของ 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

bash
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

javascript
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

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

vtrc-api/api/src/centralize/controllers/graphql.js:71-92

javascript
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

javascript
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]
REST authform body field apiKey (urlencoded)

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

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

javascript
        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

  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

อย่าสับสนกับ NestJS centralize-api

ถ้าเห็น repo centralize-api/ หรือ Swagger ที่ /docs — service คนละตัว (port 3000 REST) ไม่ใช่ปลายทางของ END_POINT_CENTRALIZE ดู Volume 3.3


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

ไป 3.1.5 Persistence layer