FLYER Teacher · PRD System Design · Sprint 110

🏗️ Kiến trúc hệ thống teacher.flyer.us

Tài liệu system design cho web ứng dụng dành cho giáo viên/trung tâm (repo partner-web, chạy song song hai domain teacher.flyer.usparent.flyer.us). Mô tả frontend stack, các tầng API (GraphQL v1/v2, REST v3/v4), auth & multi-tenant, provider tree, deployment và observability — dùng làm điểm khởi đầu cho onboarding kỹ sư mới và các quyết định mở rộng.

Phiên bản
1.0
Ngày
20/08/2026
Owner
Tùng (Product) + Team FE
Repo
partner-web (@flyer/partner)
Trạng thái
Đang chạy (production)
🔗 Điểm truy cập teacher.flyer.us ↗ parent.flyer.us ↗ Local dev: localhost:3001

1Bối cảnh & mục tiêu kiến trúc

partner-web là monolith frontend phục vụ ba nhóm người dùng khác nhau qua ba domain (nhưng cùng một Next.js ứng dụng):

DomainPersonaEntry routeENV nhận biết
teacher.flyer.usGiáo viên · Chủ trung tâm · Admin trường/exploreAPP_ENV=production · SOURCE=partner
parent.flyer.usPhụ huynh · Học sinh (self-serve)/dashboard-parentSOURCE=parent → chuyển hướng ở next.config
tools.teado.aiAnonymous dùng thử AI-first/flyer-aiHost header rewrite

Ba lý do vì sao dùng chung một codebase (thay vì tách 3 ứng dụng riêng):

Đánh đổi: bundle size lớn (~450 dependencies), thời gian dev-start chậm (openapi-gen + gqlgen chạy trước Next dev), và provider tree 14 tầng khiến việc trace bug khó hơn — nhưng team đánh giá cost tách nhỏ hơn cost đồng bộ.

2Tổng quan hệ thống

          ┌─────────────────────────────────────────────────┐
          │ Trình duyệt: teacher.flyer.us / parent.flyer.us │
          └──────────────────────┬──────────────────────────┘
                                 │ HTTPS (cookies + X-Session-Token)
                                 ▼
                    ┌────────────────────────┐
                    │  partner-web           │
                    │  Next.js 15 (K8s pod)  │
                    │  server.js (standalone)│
                    └──────────┬─────────────┘
                               │
     ┌────────────┬────────────┼────────────┬─────────────┐
     │            │            │            │             │
     ▼            ▼            ▼            ▼             ▼
┌─────────┐ ┌─────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ GraphQL │ │ GraphQL │ │ REST v3   │ │ REST v4   │ │ Static /  │
│ API v1  │ │ API v2  │ │ flyer-    │ │ teacher-  │ │ Media CDN │
│(legacy) │ │(gqlgen) │ │backend-   │ │ api       │ │           │
│         │ │         │ │rust:3000  │ │ :8081     │ │           │
└────┬────┘ └────┬────┘ └─────┬─────┘ └─────┬─────┘ └───────────┘
     │           │            │             │
     └─── PostgreSQL 17 (Hasura) ───────────┘
                             │
                             ▼
                 Read-replica ap-southeast-1
                 (analytics · truy vấn nội bộ)

Điểm cần nhớ:

3Frontend stack

Framework

  • Next.js (Pages Router, chưa migrate Ứng dụng Router)
  • output: 'standalone' — build tự đóng gói node_modules vào .next/standalone
  • TypeScript với ignoreBuildErrors: true (chưa strict)
  • Node 24.13 ở runtime container

UI toolkit

  • Ant Design (config theo persona qua ConfigProvider)
  • FlyerComponents + FlyerComponentsV2 (in-house)
  • Emotion (CSS-in-JS, có SSR cache)
  • Tailwind CSS (utilities, không phải primary)
  • MUI override CSS cho một số form legacy

Build tooling

  • pnpm 10.13.1 (packageManager pin)
  • oxlint (nhanh hơn eslint)
  • next-translate (i18n, có vi/en)
  • @next/bundle-analyzer (chạy khi ANALYZE=1)

Codegen (chạy trong pnpm dev)

  • graphql-codegen (client preset, watch mode)
  • openapi-typescript × 2 (v3 spec + v4 spec)
  • Custom scripts sinh hooks/models/responses từ OpenAPI
  • Toàn bộ output vào src/__generated__/src/api/__generated__/

Cấu trúc thư mục

src/
├── __generated__/          # GraphQL codegen (types + hooks)
├── api/
│   ├── __generated__/      # OpenAPI codegen v3
│   ├── v4/                 # OpenAPI codegen v4 (tách khỏi v3)
│   ├── client.ts           # openapi-fetch client + interceptors
│   ├── common.ts           # GraphQL queries dùng chung
│   └── hooks.ts            # Wrapper hooks kết hợp GraphQL + REST
├── app/
│   ├── Guard.tsx           # Auth guard, gate route
│   └── LoadingScreen.tsx
├── components/             # 756 file — FlyerComponents / FlyerComponentsV2 + common
├── containers/             # DialogsContainer, ProgressBarContainer
├── layouts/
│   ├── main/               # Layout chính có sidebar
│   ├── authen/             # Login/signup layout
│   └── routeConfig.tsx     # Mapping route → layout
├── pages/                  # 228 file — Next.js Pages Router
├── providers/              # 14 provider (xem §6)
│   ├── flyer/              # Auth, User, Recap, Onboarding
│   ├── teado/              # Cross-domain session token
│   └── ...
├── store/                  # Jotai atoms (theme, assign-test, ai-teacher, …)
├── hooks/                  # useSwitchSchool, useNotificationParent, …
├── lib/
│   └── datadog.ts          # trackPageView, trackUser, initDatadog
└── utils/

4Tầng API — 4 lớp song song

Frontend hiện gọi bốn hệ thống backend khác nhau. Đây là di sản của quá trình migrate (Hasura + Rust monolith → gqlgen v2 → teacher-api Go). Chưa có kế hoạch consolidate; developer phải biết chọn đúng lớp.
LớpURL trong ứng dụngRewrite tớiCodegenClientSố endpoint
GraphQL v1/api/graphqlPUBLIC_GRAPHQL_API (Hasura)graphql-codegen (schema=v2)Apollo Client 3.13~200 op
GraphQL v2/api/v2/graphqlPUBLIC_GRAPHQL_API_V2 (gqlgen Go)graphql-codegenApollo Client 3.13tăng dần
REST v3/api/v3/*flyer-backend-rust:3000openapi-typescriptopenapi-fetch + React Query182
REST v4/api/v4/*teacher-api:8081openapi-typescriptopenapi-fetch + React Query11 (giới hạn 2 module)

Khi nào dùng lớp nào — quy tắc thực tế

  1. Ưu tiên GraphQL cho các query có nhiều mảnh dữ liệu liên quan (student profile + class + attempts), vì tránh N+1 request. Ví dụ useTeacherFeatureConfigs, GetViewerQuery.
  2. REST v3 cho các flow legacy đã ổn định: auth, exam, checkpoint, tuition, wallet, package. 182 endpoint gần như bao trọn nghiệp vụ.
  3. REST v4 chỉ cho module mới, cắt tách khỏi legacy: hiện chỉ có /library và toàn bộ /wisdom-stones/* (Đá Tri Thức). Đây là "template" để mở rộng backend Go tách trách nhiệm.
  4. Không được gọi thẳng URL upstream — luôn dùng path /api/… để Next inject X-Session-Token và giữ cookie same-origin.

Auth injection tự động

// src/api/client.ts
apiClient.use({
  onRequest({ request }) {
    const token = getSessionToken()          // đọc từ ?sessTok= hoặc hash hand-off
    if (token) request.headers.set('X-Session-Token', token)
    return request
  },
})

Cookie credentials: 'include' là mặc định. Ngoài ra header device: WEB_TEACHER | WEB_PARENT được set theo hàm isPartner — backend dùng để phân tích device analytics.

Session-expired bus (global 401 handler)

// Bất kỳ 401 nào từ REST hoặc lỗi GraphQL với code = NOT_AUTHENTICATED
// đều publish qua sessionExpiredBus → hiện SessionExpiredModal ở root.
apiClient.use({
  async onResponse({ response }) {
    if (response.status === 401) notifySessionExpired()
    else {
      const body = await response.clone().json().catch(() => null)
      if (body?.code === 'NOT_AUTHENTICATED') notifySessionExpired()
    }
    return response
  },
})

5Auth & multi-tenant

Nguồn danh tính

  1. Cookie phiên (HTTP-only, do backend set) — nguồn chính, gửi qua credentials: 'include'.
  2. X-Session-Token — dùng khi hand-off cross-domain (ví dụ từ flyer.us → teacher.flyer.us qua query ?sessTok= hoặc hash). Được lưu tạm bằng sessionToken util và gắn cho mọi REST call.
  3. Google OAuth — dùng cho login đầu tiên (nút LoginWithGoogleButton), sau đó backend cấp cookie.

Guard component

app/Guard.tsx chạy sau TeadoProvider, quyết định 3 việc mỗi khi route đổi:

  1. Đã login chưa? Nếu chưa và route không nằm trong UNAUTHENTICATED_PATHS → redirect login.
  2. Đã hoàn thành onboarding chưa? Nếu thiếu bước → getCompletedOnboardingRedirect(user) đưa về đúng bước dở dang.
  3. School mismatch: nếu URL có ?schoolId=… khác currentSchool.schoolIduseSwitchSchool.switchSchool(newId).

Multi-tenant model

Khái niệmThực thể DBCơ chế chuyển
Schoolschools (40.320 rows)SwitchSchoolMutation đổi currentSchool trong cookie
Teacher profileteacher_profiles (21.107 rows)Một GV có thể thuộc nhiều trường; activeTeacherProfileId lưu trong Jotai
Classclasses (122.856 rows)Route /classes/[classId] gắn scope theo class
Custom domaincustom_domain (703 APPROVED)White-label — DNS trỏ về pod, backend map subdomain → school_id

Hệ quả cho developer: mọi query danh sách phải luôn scope theo schoolId từ context (useAuth().currentSchool); nếu quên, sẽ leak data giữa trường — tuy backend cũng chặn nhưng đừng dựa vào đó.

6State management & provider tree

Ba nguồn state

LoạiThư việnDùng cho
Server cache — GraphQLApollo Client 3.13Toàn bộ query GraphQL v1 + v2; typePolicies tùy chỉnh cho writing_check_points, speaking_check_points, speaking_parts
Server cache — RESTReact Query v5Toàn bộ v3 + v4; hooks tự sinh từ openapi
Client stateJotailoadingGlobal, activeTeacherProfileId, currentWalletAtom, teacherOnboardingInfos, per-flow state (assign-test, give-score, builder-practice, ai-teacher)

Provider tree — thứ tự 14 tầng

<ConfigProvider antd>
 <ReactQueryProvider>                   // React Query cache
  <QueryParamProvider next-adapter>      // URL ↔ state
   <CacheProvider emotion>
    <ThemeConfigProvider>                // Custom theme (school branding)
     <TailwindThemeProvider>
      <RemoteConfigProvider>             // Feature flags từ backend
       <NotistackProvider>                // Snackbar
        <FlyerProvider typePolicies=…>   // ApolloProvider + AuthProvider + UserProvider
         <TeadoProvider>                 // Cross-domain session bootstrap
          <Guard>                        // 🔒 Auth + route decision
           <OnboardingUserProvider>
            <ContentPolicyGateProvider>  // Chặn content vi phạm chính sách
             <PreRouteProvider>          // Preload critical data
              <Routes>                   // routeConfig → layout theo pathname
               <DataProvider>            // Server-side hydrate cho data-heavy pages
                <BankAccountDataProvider>
                 <BankHubProvider>       // Payment integration
                  <RecapProvider>        // Modal recap tuần/tháng
                   <Component />         // 👈 page component
Cảnh báo cho developer mới: nhét thêm một provider mới nghĩa là mỗi navigation gánh thêm re-render. Xem xét dùng hook gọi khi cần hoặc gộp vào provider có sẵn (FlyerProvider) thay vì thêm tầng thứ 15.

7Build, deploy & runtime

Pipeline hiện tại

# Build → Docker → K8s (thủ công qua scripts, chưa có GitHub Actions)
pnpm build-dev   → build .next standalone
                 → docker buildx build --push registry.flyer.vn/flyer/partner-web:dev
pnpm deploy-dev  → kubectl rollout restart deploy partner-web-dev -n development

pnpm build-prod  → docker build tag :main
pnpm deploy-prod → kubectl rollout restart deploy partner-web -n production

Runtime env injection — @beam-australia/react-env

Container entrypoint chạy react-env --env APP_ENV --prefix PUBLIC trước Next → sinh __ENV.js chứa toàn bộ biến PUBLIC_* tại thời điểm start pod. Nhờ vậy một image chung dùng được cho dev/staging/prod, chỉ đổi ConfigMap.

Env quan trọngDùng cho
PUBLIC_BASE_APIBase URL khi server-side render gọi API
PUBLIC_BASE_MEDIA_URLCDN ảnh (dùng qua rewrite /image/*)
PUBLIC_GRAPHQL_API / _V2Endpoint GraphQL v1/v2
FLYER_RUST_API / FLYER_BACKEND_V4_APIServer-side rewrite target (không lộ ra client)
TOOL_URLNhận diện host tools.teado.ai để rewrite / → /flyer-ai

Dockerfile — điểm bất thường

ENTRYPOINT node ./slack/slack.mjs && \
  react-env --env APP_ENV --prefix PUBLIC -- node server.js

Slack side-car: mỗi lần pod khởi động, script slack.mjs post message vào Slack (deploy notification / rollback alert). Chạy nối tiếp qua && — nếu Slack fail, pod fail luôn. Đây là điểm phải chú ý khi Slack rate-limit.

8Observability & error surfaces

Datadog Browser SDK

Google tracking

GoogleAnalytics + GoogleTagManager load qua @next/third-parties/google ở root _app.tsx; page view emit qua router.events.on('routeChangeComplete', …).

User-facing errors

Kịch bảnCơ chếUI
Session hết hạn / cookie xoásessionExpiredBus.notify() phát eventSessionExpiredModal ở root, buộc reload sau khi login lại
Content policy vi phạmContentPolicyGateProvider đọc flag từ ViewerFull-page gate + hướng dẫn khắc phục
Network failReact Query retry (default 3 lần)Notistack snackbar (toast) — không block UI
Route lỗi (404/500)Next.js defaultTrang pages/404.tsx, 500.tsx tùy chỉnh nhẹ

9Rủi ro kỹ thuật hiện tại

Rủi roMứcGhi chú
TypeScript strict tắt (ignoreBuildErrors: true)CaoBuild vẫn pass khi có type error → dễ merge bug. Cần bật strict theo module (module-by-module).
Provider tree 14 tầngTrung bìnhMỗi lần login re-render toàn bộ; khó test unit; onboarding kỹ sư mới lâu.
Đồng thời 4 lớp APITrung bìnhCùng một entity (user, class) có nhiều nguồn khác nhau → cache inconsistency (đã có typePolicies patch nhưng chưa đủ).
Slack side-car chặn bootThấp-TBNếu Slack API 429/timeout → pod restart loop. Nên đổi sang node slack.mjs & … hoặc tách CronJob.
Không có CI (không có .github/workflows)CaoBuild/test/deploy đều thủ công qua script + kubectl. Không có gate về lint/type/test trước merge.
228 file page (Pages Router)Cần theo dõiNhiều page kế thừa nhau qua HOC/wrap → khó reason về data fetching. Migrate Ứng dụng Router sẽ giúp nhưng chi phí cao.
openapi-fetch không type-narrow 4xxCần theo dõiResponse type union {data, error} — dev dễ quên check error. Có wrapper hooks nhưng chưa dùng nhất quán.
Bundle size chưa đo định kỳCần theo dõiChỉ chạy pnpm analyze ad-hoc. CKEditor + Ant Design + MUI cùng tồn tại đẩy vendor bundle rất lớn.

10Ngoài phạm vi / tương lai

Đã nằm trong backlog

Chưa quyết

Kỳ vọng đo được sau 1 sprint


← Danh mục PRD · Kiểm chứng số liệu production · Roadmap chính